diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index c6c8b36..0000000 --- a/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true diff --git a/.gitignore b/.gitignore index 92f86aa..3e27ec5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,16 @@ -.idea - +# Build target project/target + +# IntelliJ +.idea + +# VS Code +.vscode + +# Metals +.bloop +.bsp +.metals +metals.sbt +project/project diff --git a/.scalafix.conf b/.scalafix.conf new file mode 100644 index 0000000..57bf7db --- /dev/null +++ b/.scalafix.conf @@ -0,0 +1,17 @@ +// https://github.com/liancheng/scalafix-organize-imports +OrganizeImports { + blankLines = Auto + coalesceToWildcardImportThreshold = 5 + expandRelative = false + groupExplicitlyImportedImplicitsSeparately = false + groupedImports = Merge + groups = [ + "*" + "java." + "scala." + ] + importSelectorsOrder = Ascii + importsOrder = Ascii + preset = DEFAULT + removeUnused = true +} diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..4d9c342 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0-RC2" + +runner.dialect = scala3 + +newlines.topLevelStatements = [before] +newlines.afterCurlyLambdaParams = preserve + +docstrings.wrap = "no" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf41fc..ff2d96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,15 @@ # Changelog -## Version 1.0.0 (2017-11-12) +All notable changes to this project will be documented in this file. -### New features -- Encode and decode JWT token -- suppot hashing algorithms: -- - HS256 -- - HS384 -- - HS512 -- - RS256 -- - RS384 -- - RS512 -- - ES256 -- - ES384 -- - ES512 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] + +### ✨ Feature + +* Add CHANGELOG to root directory + +## [1.0.0] + +* Encode and decode JWT tokens support for following hashing algorithms (HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384 and ES512) diff --git a/README.md b/README.md index 725036b..2fff878 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,19 @@ # spray-jwt + JWT library to use with spray-json and akka-http. ## Install + Add spray-jwt as dependency to your `build.sbt`: -```sbtshell +```sbt libraryDependencies ++= Seq( "com.github.janjaali" %% "spray-jwt" % "1.0.0" ) ``` To encode a JsValue to a JWT token: + ```scala import org.janjaali.sprayjwt.Jwt import org.janjaali.sprayjwt.algorithms.HS256 @@ -20,6 +23,7 @@ val jwtOpt = Jwt.encode(payload, "super_fancy_secret", HS256) ``` And vice versa to decode JWT token as a JsValue: + ```scala import org.janjaali.sprayjwt.Jwt import org.janjaali.sprayjwt.algorithms.HS256 @@ -29,17 +33,11 @@ val jsValueOpt = Jwt.decode(token, "super_fancy_secret") ``` ## Supported algorithms -- HS256 -- HS384 -- HS512 - -- RS256 -- RS384 -- RS512 -## Source Code Style -Check style via [scalastyle](http://www.scalastyle.org/): +* HS256 +* HS384 +* HS512 -```sbtshell -sbt scalastyle -``` +* RS256 +* RS384 +* RS512 diff --git a/build.sbt b/build.sbt index c5ca7cb..55754ef 100644 --- a/build.sbt +++ b/build.sbt @@ -1,70 +1,60 @@ -import scala.sys.process._ - -name := "spray-jwt" - -organization := "com.github.janjaali" - -version := "1.0.0" - -licenses := Seq("MIT License" -> url("https://opensource.org/licenses/MIT")) - -homepage := Some(url("https://github.com/janjaali/spray-jwt")) - -scmInfo := Some( - ScmInfo( - url("https://github.com/janjaali/spray-jwt"), - "scm:git@github.com/janjaali/spray-jwt.git" - ) -) - -developers := List( - Developer( - id = "ghashange", - name = "ghashange", - email = "", - url = url("https://github.com/janjaali") +ThisBuild / scalaVersion := "3.0.0-RC3" + +ThisBuild / versionScheme := Some("early-semver") + +lazy val sprayJwt = (project in file("spray-jwt")) + .settings( + name := "spray-jwt", + organization := "com.github.janjaali", + version := "1.0.0", + + licenses := Seq( + "MIT License" -> url("https://opensource.org/licenses/MIT") + ), + homepage := Some(url("https://github.com/janjaali/spray-jwt")), + scmInfo := Some( + ScmInfo( + browseUrl = url("https://github.com/janjaali/spray-jwt"), + connection = "scm:git@github.com/janjaali/spray-jwt.git" + ) + ), + developers := List( + Developer( + id = "janjaali", + name = "janjaali", + email = "", + url = url("https://github.com/janjaali") + ) + ), + + publishMavenStyle := true, + publishTo := { + val nexus = "https://oss.sonatype.org/" + if (isSnapshot.value) { + Some("snapshots" at nexus + "content/repositories/snapshots") + } else { + Some("releases" at nexus + "service/local/staging/deploy/maven2") + } + }, + + libraryDependencies ++= Seq( + // JSON + ("io.spray" %% "spray-json" % "1.3.6").cross(CrossVersion.for3Use2_13), + // Encryption + "org.bouncycastle" % "bcpkix-jdk15on" % "1.58", + // Test + "org.scalatest" %% "scalatest" % "3.2.8" % Test, + // Property based tests + "org.scalacheck" %% "scalacheck" % "1.15.3" % Test, + "org.scalatestplus" %% "scalacheck-1-15" % "3.2.8.0" % Test + ) ) -) - -scalaVersion := "2.12.3" - -publishMavenStyle := true -publishArtifact in Test := false - -publishTo := { - val nexus = "https://oss.sonatype.org/" - if (isSnapshot.value) { - Some("snapshots" at nexus + "content/repositories/snapshots") - } else { - Some("releases" at nexus + "service/local/staging/deploy/maven2") +lazy val sprayJsonSupport = (project in file("spray-json-support")) + .dependsOn(sprayJwt % "test->test;compile->compile") + .settings { + libraryDependencies ++= Seq( + // Supported JSON library + ("io.spray" %% "spray-json" % "1.3.6").cross(CrossVersion.for3Use2_13) + ) } -} - -val testDependencies = Seq( - "org.scalatest" %% "scalatest" % "3.0.4" % "test" -) - -val dependencies = Seq( - "io.spray" %% "spray-json" % "1.3.3", - "org.bouncycastle" % "bcpkix-jdk15on" % "1.58" -) - -libraryDependencies ++= dependencies -libraryDependencies ++= testDependencies - -lazy val scalastyleTest = taskKey[Unit]("scalastyleTest") -scalastyleTest := (scalastyle in Test).toTask("").value - -(scalastyle in Compile) := ((scalastyle in Compile) dependsOn scalastyleTest).toTask("").value - -lazy val installGitHook = taskKey[Unit]("Installs git hooks") -installGitHook := { - if (sys.props("os.name").contains("Windows")) { - "cmd /c copy scripts\\pre-commit-hook.sh .git\\hooks\\pre-commit" ! - } else { - "cp scripts/pre-commit-hook.sh .git/hooks/pre-commit" ! - } -} - -(compile in Compile) := ((compile in Compile) dependsOn installGitHook).value diff --git a/project/build.properties b/project/build.properties index 017bb86..7bb94aa 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.0.2 +sbt.version = 1.5.1 diff --git a/project/plugins.sbt b/project/plugins.sbt deleted file mode 100644 index b7728a3..0000000 --- a/project/plugins.sbt +++ /dev/null @@ -1,3 +0,0 @@ -addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0") - -addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.0") diff --git a/scalastyle-config.xml b/scalastyle-config.xml deleted file mode 100644 index 66d8a97..0000000 --- a/scalastyle-config.xml +++ /dev/null @@ -1,98 +0,0 @@ - - Scalastyle standard configuration - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/scripts/pre-commit-hook.sh b/scripts/pre-commit-hook.sh deleted file mode 100644 index e03693b..0000000 --- a/scripts/pre-commit-hook.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash - -echo "" -echo "Doing some Checks..." -echo "* Stashing non-staged changes so we don't check them" -git diff --quiet -hadNoNonStagedChanges=$? - -if ! [ $hadNoNonStagedChanges -eq 0 ] -then - git stash --keep-index -u > /dev/null -fi - -echo "* Compiling..." -sbt test:compile > /dev/null -compiles=$? - -if [ $compiles -ne 0 ] -then - echo " [KO] Error compiling " -else - echo "* Testing..." - sbt test > /dev/null - test=$? - if [ $test -ne 0 ] - then - echo " [KO] Test failures " - else - echo "* Checking code style…" - - sbt scalastyle > /dev/null - productionScalastyle=$? - - if [ $productionScalastyle -ne 0 ] - then - echo " [KO] Error checking code style" - fi - fi -fi - -echo "* Applying the stash with the non-staged changes…" -if ! [ $hadNoNonStagedChanges -eq 0 ] -then - sleep 1 && git stash pop --index > /dev/null & # sleep because otherwise commit fails when this leads to a merge conflict -fi - -# Final result -echo "" - -if [ $compiles -eq 0 ] && [ $productionScalastyle -eq 0 ] && [ $test -eq 0 ] -then - echo "[OK] Your code will be committed young padawan" - exit 0 -elif [ $compiles -ne 0 ] -then - echo "[KO] Cancelling commit due to compile error (run 'sbt test:compile' for more information)" - exit 1 -elif [ $test -ne 0 ] -then - echo "[KO] Cancelling commit due to test failures (run 'sbt test' for more information)" - exit 1 -elif [ $productionScalastyle -ne 0 ] -then - echo "[KO] Cancelling commit due to code style error (run 'sbt scalastyle' for more information)" - exit 2 -fi diff --git a/spray-json-support/src/main/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringDeserializer.scala b/spray-json-support/src/main/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringDeserializer.scala new file mode 100644 index 0000000..c42dd31 --- /dev/null +++ b/spray-json-support/src/main/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringDeserializer.scala @@ -0,0 +1,28 @@ +package org.janjaali.sprayjwt.sprayjson + +import org.janjaali.sprayjwt.json.* +import spray.json.* + +/** spray-json implementation of the JsonStringDeserializer. + */ +object SprayJsonStringDeserializer extends JsonStringDeserializer: + + override def deserialize(jsonText: String): JsonValue = + asJsonValue(jsonText.parseJson) + + private def asJsonValue(jsValue: JsValue): JsonValue = + jsValue match + case JsObject(fields) => + JsonObject( + fields.map { case (name, jsValue) => + name -> asJsonValue(jsValue) + } + ) + + case JsArray(elements) => + JsonArray(elements.map(asJsonValue)) + + case JsString(value) => JsonString(value) + case JsNumber(value) => JsonNumber(value) + case jsBoolean: JsBoolean => JsonBoolean(jsBoolean.value) + case JsNull => JsonNull diff --git a/spray-json-support/src/main/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringSerializer.scala b/spray-json-support/src/main/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringSerializer.scala new file mode 100644 index 0000000..f229227 --- /dev/null +++ b/spray-json-support/src/main/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringSerializer.scala @@ -0,0 +1,30 @@ +package org.janjaali.sprayjwt.sprayjson + +import org.janjaali.sprayjwt.json.* +import spray.json.* + +/** spray-json implementation of the JsonStringSerializer. + */ +object SprayJsonStringSerializer extends JsonStringSerializer: + + override def serialize(jsonValue: JsonValue): String = + asJsValue(jsonValue).compactPrint + + private def asJsValue(jsonValue: JsonValue): JsValue = + jsonValue match + case JsonObject(members) => + JsObject( + members.map { case (key, value) => + key -> asJsValue(value) + }.toMap + ) + + case JsonArray(elements) => + JsArray( + elements.map(asJsValue).toVector + ) + + case JsonString(value) => JsString(value) + case JsonNumber(value) => JsNumber(value) + case JsonBoolean(value) => JsBoolean(value) + case JsonNull => JsNull diff --git a/spray-json-support/src/test/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringDeserializerSpec.scala b/spray-json-support/src/test/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringDeserializerSpec.scala new file mode 100644 index 0000000..d324757 --- /dev/null +++ b/spray-json-support/src/test/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringDeserializerSpec.scala @@ -0,0 +1,90 @@ +package org.janjaali.sprayjwt.sprayjson + +import org.janjaali.sprayjwt.json._ +import org.janjaali.sprayjwt.tests.ScalaTestSpec +import spray.json._ + +final class SprayJsonStringDeserializerSpec extends ScalaTestSpec: + + "SprayJsonStringDeserializer" - { + + val sut = SprayJsonStringDeserializer + + "deserializes" - { + + "JSON objects" - { + + "that are empty." in { + + val jsonText = "{}" + + sut.deserialize(jsonText) shouldBe JsonObject.empty + } + + "that are not empty." in { + + val jsonText = """{ + | "key":"value", + | "otherKey":42} + |""".stripMargin + + sut.deserialize(jsonText) shouldBe JsonObject( + Map( + "key" -> JsonString("value"), + "otherKey" -> JsonNumber(42) + ) + ) + } + } + + "JSON arrays" - { + + "that are empty." in { + + val jsonText = "[]" + + sut.deserialize(jsonText) shouldBe JsonArray.empty + } + + "that are not empty." in { + + val jsonText = """["value",42]""" + + sut.deserialize(jsonText) shouldBe JsonArray( + Seq( + JsonString("value"), + JsonNumber(42) + ) + ) + } + } + + "JSON strings." in { + + val jsonText = "\"dance\"" + + sut.deserialize(jsonText) shouldBe JsonString("dance") + } + + "JSON numbers." in { + + val jsonText = "42" + + sut.deserialize(jsonText) shouldBe JsonNumber(42) + } + + "JSON booleans." in { + + val jsonText = "true" + + sut.deserialize(jsonText) shouldBe JsonBoolean(true) + } + + "JSON nulls." in { + + val jsonText = "null" + + sut.deserialize(jsonText) shouldBe JsonNull + } + } + } diff --git a/spray-json-support/src/test/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringSerializerSpec.scala b/spray-json-support/src/test/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringSerializerSpec.scala new file mode 100644 index 0000000..a9e7ddb --- /dev/null +++ b/spray-json-support/src/test/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonStringSerializerSpec.scala @@ -0,0 +1,88 @@ +package org.janjaali.sprayjwt.sprayjson + +import org.janjaali.sprayjwt.json._ +import org.janjaali.sprayjwt.tests.ScalaTestSpec +import spray.json._ + +final class SprayJsonStringSerializerSpec extends ScalaTestSpec: + + "SprayJsonStringSerializer" - { + + val sut = SprayJsonStringSerializer + + "serializes" - { + + "JSON objects" - { + + "that are empty." in { + + val jsonValue = JsonObject.empty + + sut.serialize(jsonValue) shouldBe "{}" + } + + "that are not empty." in { + + val jsonValue = JsonObject( + Map( + "key" -> JsonString("value"), + "otherKey" -> JsonNumber(42) + ) + ) + + sut.serialize(jsonValue) shouldBe + """{"key":"value","otherKey":42}""" + } + } + + "JSON arrays" - { + + "that are empty." in { + + val jsonValue = JsonArray.empty + + sut.serialize(jsonValue) shouldBe "[]" + } + + "that are not empty." in { + + val jsonValue = JsonArray( + Seq( + JsonString("value"), + JsonNumber(42) + ) + ) + + sut.serialize(jsonValue) shouldBe """["value",42]""" + } + } + + "JSON strings." in { + + val jsonValue = JsonString("dance") + + sut.serialize(jsonValue) shouldBe "\"dance\"" + } + + "JSON numbers." in { + + val jsonValue = JsonNumber(42) + + sut.serialize(jsonValue) shouldBe "42" + } + + "JSON booleans." in { + + val jsonValue = JsonBoolean(true) + + sut.serialize(jsonValue) shouldBe "true" + } + + "JSON nulls." in { + + val jsonValue = JsonNull + + sut.serialize(jsonValue) shouldBe "null" + } + } + } diff --git a/spray-json-support/src/test/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonSupportSpec.scala b/spray-json-support/src/test/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonSupportSpec.scala new file mode 100644 index 0000000..5b52692 --- /dev/null +++ b/spray-json-support/src/test/scala/org/janjaali/sprayjwt/sprayjson/SprayJsonSupportSpec.scala @@ -0,0 +1,20 @@ +package org.janjaali.sprayjwt.sprayjson + +import org.janjaali.sprayjwt.algorithms.JsonSupportSpec +import org.janjaali.sprayjwt.json.JsonStringSerializer +import org.janjaali.sprayjwt.json.JsonStringDeserializer + +final class SprayJsonSupportSpec extends JsonSupportSpec: + + override protected given jsonStringSerializer: JsonStringSerializer = + SprayJsonStringSerializer + + override protected given jsonStringDeserializer: JsonStringDeserializer = + SprayJsonStringDeserializer + + "spray-json support" - { + + verifySignWithHmacAlgorithms() + + verifyValidationWithHmacAlgorithms() + } diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/algorithms/Algorithm.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/algorithms/Algorithm.scala new file mode 100644 index 0000000..88269ba --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/algorithms/Algorithm.scala @@ -0,0 +1,252 @@ +package org.janjaali.sprayjwt.algorithms + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import org.bouncycastle.openssl.{PEMKeyPair, PEMParser} +import org.janjaali.sprayjwt.encoder.{ + Base64UrlDecoder, + Base64UrlEncoder, + ByteEncoder +} +import org.janjaali.sprayjwt.json.* +import org.janjaali.sprayjwt.jws.{Header, JoseHeader, JwsPayload, JwsSignature} + +import java.io.{IOException, StringReader} +import java.security.{PrivateKey, PublicKey, Signature} + +/** Represents a cryptographic algorithm used with JWT. + */ +sealed trait Algorithm: + + /** Digitally signs the protected headers of the given Jose Header and the Jws + * Payload with a given secret. + * + * @param joseHeader jose header + * @param jwsPayload jws payload + * @param secret secret + */ + def sign( + joseHeader: JoseHeader, + jwsPayload: JwsPayload, + secret: Secret + )(using jsonStringSerializer: JsonStringSerializer): JwsSignature + + /** Validates a given JWT token. + */ + def validate(data: String, secret: Secret): Boolean + +/** Algorithms. + */ +object Algorithms: + + /** Hash-based Message Authentication Codes (HMACs) algorithm to sign and + * validate digital signatures. + */ + sealed trait Hmac extends Algorithm: + + protected def hashingAlgorithmName: String + + override def sign( + joseHeader: JoseHeader, + jwsPayload: JwsPayload, + secret: Secret + )(using jsonStringSerializer: JsonStringSerializer): JwsSignature = + val base64UrlEncodedJoseHeader = + Base64UrlEncoder.encode( + jsonStringSerializer.serialize(joseHeader.asJson) + ) + + val base64UrlEncodedJwsPayload = + Base64UrlEncoder.encode( + jsonStringSerializer.serialize(jwsPayload.asJson) + ) + + val inputToBeSigned = + s"$base64UrlEncodedJoseHeader.$base64UrlEncodedJwsPayload" + + sign(inputToBeSigned, secret) + + override def validate( + data: String, + secret: Secret + ): Boolean = + data.split("\\.") match + case Array( + base64UrlEncodedJoseHeader, + base64UrlEncodedJwsPayload, + base64EncodedSignature + ) => + val signedInput = { + s"$base64UrlEncodedJoseHeader.$base64UrlEncodedJwsPayload" + } + + sign(signedInput, secret).value == base64EncodedSignature + + case _ => false + + private def sign(data: String, secret: Secret): JwsSignature = + val mac = Mac.getInstance(hashingAlgorithmName) + val key = new SecretKeySpec(secret.asByteArray, hashingAlgorithmName) + + mac.init(key) + + val signature = mac.doFinal(data.getBytes("UTF-8")) + + JwsSignature(Base64UrlEncoder.encode(signature)) + + /** RSASSA-PKCS1-v1_5 (RSA) based algorithm using SHA-2 hash functions to sign + * and validate digital signatures. + */ + sealed trait Rsa extends Algorithm: + + private val provider = "BC" + + protected def hashingAlgorithmName: String + + override def sign( + joseHeader: JoseHeader, + jwsPayload: JwsPayload, + secret: Secret + )(using jsonStringSerializer: JsonStringSerializer): JwsSignature = ??? + + override def validate(data: String, secret: Secret): Boolean = ??? + + // TODO: Check implementation + def sign( + data: String, + secret: String + )(using serializeJson: JsonValue => String): String = { + + val key = getPrivateKey(secret) + + val dataByteArray = ByteEncoder.getBytes(data) + + val signature = Signature.getInstance(hashingAlgorithmName, provider) + signature.initSign(key) + signature.update(dataByteArray) + val signatureByteArray = signature.sign + Base64UrlEncoder.encode(signatureByteArray) + } + + // TODO: Check implementation + def validate( + signature: String, + data: String, + secret: String + )(implicit + serializeJson: JsonValue => String, + base64UrlEncoder: Base64UrlEncoder + ): Boolean = { + + val key = getPublicKey(secret) + + val dataByteArray = ByteEncoder.getBytes(data) + + val rsaSignature = Signature.getInstance(hashingAlgorithmName, provider) + rsaSignature.initVerify(key) + rsaSignature.update(dataByteArray) + rsaSignature.verify(Base64UrlDecoder.decode(signature)) + } + + private def getPublicKey(str: String): PublicKey = { + val pemParser = new PEMParser(new StringReader(str)) + val keyPair = pemParser.readObject() + + Option(keyPair) match { + case Some(publicKeyInfo: SubjectPublicKeyInfo) => + val converter = new JcaPEMKeyConverter + converter.getPublicKey(publicKeyInfo) + case _ => + throw new IOException(s"Invalid key for $hashingAlgorithmName") + } + } + + private def getPrivateKey(str: String): PrivateKey = { + val pemParser = new PEMParser(new StringReader(str)) + val keyPair = pemParser.readObject() + + Option(keyPair) match { + case Some(keyPair: PEMKeyPair) => + val converter = new JcaPEMKeyConverter + converter.getKeyPair(keyPair).getPrivate + case _ => + throw new IOException(s"Invalid key for $hashingAlgorithmName") + } + } + + /** HMAC using SHA-256. + */ + case object Hs256 extends Hmac: + override val hashingAlgorithmName = "HMACSHA256" + + /** HMAC using SHA-384. + */ + case object Hs384 extends Hmac: + override val hashingAlgorithmName = "HMACSHA384" + + /** HMAC using SHA-512. + */ + case object Hs512 extends Hmac: + override val hashingAlgorithmName = "HMACSHA512" + + /** RSASSA-PKCS1-v1_5 using SHA-256. + */ + case object Rs256 extends Rsa: + override protected def hashingAlgorithmName: String = "SHA256withRSA" + + /** RSASSA-PKCS1-v1_5 using SHA-384. + */ + case object Rs384 extends Rsa: + override protected def hashingAlgorithmName: String = "SHA384withRSA" + + /** RSASSA-PKCS1-v1_5 using SHA-512. + */ + case object Rs512 extends Rsa: + override protected def hashingAlgorithmName: String = "SHA512withRSA" + + // TODO: Docs. + // TODO: Test. + // TODO: Move to the right place. + def validate( + data: String, + secret: Secret // TODO: Do RSA algorithms use the same kind of secret, probably not? + )(using jsonStringDeserializer: JsonStringDeserializer): Boolean = { + + val maybeAlgorithm = { + data.split("\\.") match + case Array( + base64UrlEncodedJoseHeader, + base64UrlEncodedJwsPayload, + base64EncodedSignature + ) => + val maybeJoseHeaderJsonObject = jsonStringDeserializer.deserialize { + Base64UrlDecoder.decodeAsString { + base64UrlEncodedJoseHeader + } + } + + maybeJoseHeaderJsonObject match { + case joseHeaderJsonObject: JsonObject => + val joseHeader = JoseHeader(joseHeaderJsonObject) + + joseHeader.headers.collectFirst { + case Header.Algorithm(algorithm) => algorithm + } + case _ => + // TODO: Log. + None + } + case _ => + // TODO: Log. + None + } + + maybeAlgorithm match { + case Some(algorithm) => + algorithm.validate(data, secret) + case None => + false + } + } diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/algorithms/Secret.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/algorithms/Secret.scala new file mode 100644 index 0000000..5ebc9d1 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/algorithms/Secret.scala @@ -0,0 +1,14 @@ +package org.janjaali.sprayjwt.algorithms + +/** Represents a String based secret value. + */ +final case class Secret(value: String) extends AnyVal { + + /** Encodes this secret value as a byte array using the UTF-8 charset. + * + * @return byte-array + */ + def asByteArray: Array[Byte] = { + value.getBytes("UTF-8") + } +} diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/encoder/Base64UrlDecoder.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/encoder/Base64UrlDecoder.scala new file mode 100644 index 0000000..43b96e1 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/encoder/Base64UrlDecoder.scala @@ -0,0 +1,33 @@ +package org.janjaali.sprayjwt.encoder + +import java.util.Base64 + +/** Default Base64 URL decoder that uses internally the [[java.util.Base64]] URL + * decoder. + */ +private[sprayjwt] trait Base64UrlDecoder { + + private lazy val decoder: Base64.Decoder = Base64.getUrlDecoder + + /** Decodes Base64 URL encoded text as ByteArray. + * + * @param text text that should be decoded + * @return Base64 URL decoded ByteArray + */ + def decode(text: String): Array[Byte] = { + decoder.decode(text) + } + + /** Decodes Base64 URL encoded text as String. + * + * @param text text that should be decoded + * @return Base64 URL decoded String + */ + def decodeAsString(text: String): String = { + new String(decode(text)) + } +} + +/** Base64 URL decoder utilities. + */ +private[sprayjwt] object Base64UrlDecoder extends Base64UrlDecoder diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/encoder/Base64UrlEncoder.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/encoder/Base64UrlEncoder.scala new file mode 100644 index 0000000..06a9d51 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/encoder/Base64UrlEncoder.scala @@ -0,0 +1,30 @@ +package org.janjaali.sprayjwt.encoder + +import java.util.Base64 + +/** Default Base64 URL encoder that uses internally the [[java.util.Base64]] URL + * encoder without padding. + */ +private[sprayjwt] trait Base64UrlEncoder: + + private lazy val encoder: Base64.Encoder = Base64.getUrlEncoder.withoutPadding + + /** Encodes text to a Base64 URL encoded String. + * + * @param text text that should be encoded + * @return Base64 URL encoded String + */ + def encode(text: String): String = + encode(text.getBytes("UTF-8")) + + /** Encodes a byte-array as Base64 URL encoded String. + * + * @param byteArray ByteArray to encode as String + * @return String encoded ByteArray + */ + def encode(byteArray: Array[Byte]): String = + encoder.encodeToString(byteArray) + +/** Base64 URL encoder utilities. + */ +private[sprayjwt] object Base64UrlEncoder extends Base64UrlEncoder diff --git a/src/main/scala/org/janjaali/sprayjwt/encoder/ByteEncoder.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/encoder/ByteEncoder.scala similarity index 94% rename from src/main/scala/org/janjaali/sprayjwt/encoder/ByteEncoder.scala rename to spray-jwt/src/main/scala/org/janjaali/sprayjwt/encoder/ByteEncoder.scala index f6c631a..5ff9588 100644 --- a/src/main/scala/org/janjaali/sprayjwt/encoder/ByteEncoder.scala +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/encoder/ByteEncoder.scala @@ -7,6 +7,8 @@ private[sprayjwt] object ByteEncoder { private val encodingCharset = "UTF-8" + // TODO: Check usage? + /** * Encodes text into a byte array used UTF-8 charset. * diff --git a/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtAlgorithmException.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtAlgorithmException.scala similarity index 100% rename from src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtAlgorithmException.scala rename to spray-jwt/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtAlgorithmException.scala diff --git a/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtException.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtException.scala similarity index 100% rename from src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtException.scala rename to spray-jwt/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtException.scala diff --git a/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtHeaderException.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtHeaderException.scala similarity index 100% rename from src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtHeaderException.scala rename to spray-jwt/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidJwtHeaderException.scala diff --git a/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidSignatureException.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidSignatureException.scala similarity index 100% rename from src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidSignatureException.scala rename to spray-jwt/src/main/scala/org/janjaali/sprayjwt/exceptions/InvalidSignatureException.scala diff --git a/src/main/scala/org/janjaali/sprayjwt/headers/JwtHeader.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/headers/JwtHeader.scala similarity index 66% rename from src/main/scala/org/janjaali/sprayjwt/headers/JwtHeader.scala rename to spray-jwt/src/main/scala/org/janjaali/sprayjwt/headers/JwtHeader.scala index f1264c0..8c7c024 100644 --- a/src/main/scala/org/janjaali/sprayjwt/headers/JwtHeader.scala +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/headers/JwtHeader.scala @@ -1,13 +1,13 @@ package org.janjaali.sprayjwt.headers -import org.janjaali.sprayjwt.algorithms.HashingAlgorithm +import org.janjaali.sprayjwt.algorithms.Algorithm /** * Represents JWT-Header. * * @param algorithm the hashing algorithm which is used for encoding the JWT */ -case class JwtHeader(algorithm: HashingAlgorithm) { +case class JwtHeader(algorithm: Algorithm) { /** * Default type. */ diff --git a/src/main/scala/org/janjaali/sprayjwt/headers/headers.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/headers/headers.scala similarity index 55% rename from src/main/scala/org/janjaali/sprayjwt/headers/headers.scala rename to spray-jwt/src/main/scala/org/janjaali/sprayjwt/headers/headers.scala index 4cdecca..139de02 100644 --- a/src/main/scala/org/janjaali/sprayjwt/headers/headers.scala +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/headers/headers.scala @@ -1,6 +1,6 @@ package org.janjaali.sprayjwt -import org.janjaali.sprayjwt.algorithms.HashingAlgorithm +import org.janjaali.sprayjwt.algorithms.{Algorithm, Algorithms} import org.janjaali.sprayjwt.exceptions.{InvalidJwtAlgorithmException, InvalidJwtHeaderException} import spray.json.{JsObject, JsString, JsValue, JsonReader, JsonWriter} @@ -14,8 +14,19 @@ package object headers { */ implicit object JwtHeaderJsonWriter extends JsonWriter[JwtHeader] { def write(jwtHeader: JwtHeader): JsValue = { + + // TODO: Extract hardcoded strings out + val algorithmIdentifier = jwtHeader.algorithm match { + case Algorithms.Rs256 => "RS256" + case Algorithms.Rs384 => "RS384" + case Algorithms.Rs512 => "RS512" + case Algorithms.Hs256 => "HS256" + case Algorithms.Hs384 => "HS384" + case Algorithms.Hs512 => "HS512" + } + JsObject( - "alg" -> JsString(jwtHeader.algorithm.name), + "alg" -> JsString(algorithmIdentifier), "typ" -> JsString(jwtHeader.typ) ) } @@ -28,7 +39,17 @@ package object headers { override def read(json: JsValue): JwtHeader = { json.asJsObject.getFields("alg", "typ") match { case Seq(JsString(alg), JsString(typ)) if typ == "JWT" => - HashingAlgorithm(alg) match { + + val maybeAlgorithm: Option[Algorithm] = alg match { + case "HS256" => Some(Algorithms.Hs256) + case "HS384" => Some(Algorithms.Hs384) + case "HS512" => Some(Algorithms.Hs512) + case "RS256" => Some(Algorithms.Rs256) + case "RS384" => Some(Algorithms.Rs384) + case "RS512" => Some(Algorithms.Rs512) + } + + maybeAlgorithm match { case Some(algorithm) => JwtHeader(algorithm) case _ => throw new InvalidJwtAlgorithmException(s"Unsupported JWT algorithm $alg") } @@ -36,5 +57,4 @@ package object headers { } } } - } diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/CommonJsonWriters.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/CommonJsonWriters.scala new file mode 100644 index 0000000..c67c2ab --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/CommonJsonWriters.scala @@ -0,0 +1,124 @@ +package org.janjaali.sprayjwt.json + +/** Commonly used JSON writers for commonly used types. + */ +object CommonJsonWriters { + + /** Implicitly usable CommonJsonWriters. + */ + object Implicits { + + /** Constructs JSON writer for Int types. + * + * @return JSON writer + */ + implicit def intJsonWriter: JsonWriter[Int] = { + CommonJsonWriters.intJsonWriter + } + + /** Constructs JSON writer for Long types. + * + * @return JSON writer + */ + implicit def longJsonWriter: JsonWriter[Long] = { + CommonJsonWriters.longJsonWriter + } + + /** Constructs JSON writer for Boolean types. + * + * @return JSON writer + */ + implicit def booleanJsonWriter: JsonWriter[Boolean] = { + CommonJsonWriters.booleanJsonWriter + } + + /** Constructs JSON writer for String types. + * + * @return JSON writer + */ + implicit def stringJsonWriter: JsonWriter[String] = { + CommonJsonWriters.stringJsonWriter + } + + /** Constructs JSON writer for JSON values. + * + * @return JSON writer + */ + implicit def jsonValueJsonWriter: JsonWriter[JsonValue] = { + CommonJsonWriters.jsonValueJsonWriter + } + } + + /** Constructs JSON writer for Int types. + * + * @return JSON writer + */ + def intJsonWriter: JsonWriter[Int] = { + new JsonWriter[Int] { + override def write(value: Int): JsonValue = { + JsonNumber(BigDecimal(value)) + } + } + } + + /** Constructs JSON writer for Long types. + * + * @return JSON writer + */ + def longJsonWriter: JsonWriter[Long] = { + new JsonWriter[Long] { + override def write(value: Long): JsonValue = { + JsonNumber(value) + } + } + } + + /** Constructs JSON writer for Boolean types. + * + * @return JSON writer + */ + def booleanJsonWriter: JsonWriter[Boolean] = { + new JsonWriter[Boolean] { + override def write(value: Boolean): JsonValue = { + JsonBoolean(value) + } + } + } + + /** Constructs JSON writer for String types. + * + * @return JSON writer + */ + def stringJsonWriter: JsonWriter[String] = { + new JsonWriter[String] { + override def write(value: String): JsonValue = { + JsonString(value) + } + } + } + + /** Constructs flat JSON writer for product types with at least an arity of 1. + * + * Ignores all other elements for JSON writing than the first element. + * + * @return JSON writer + */ + def flatValueJsonWriter[P <: Product, T: JsonWriter]: JsonWriter[P] = { + new JsonWriter[P] { + def write(product: P): JsonValue = { + val valueWriter = implicitly[JsonWriter[T]] + valueWriter.write(product.productElement(0).asInstanceOf[T]) + } + } + } + + /** Constructs JSON writer for JSON values. + * + * @return JSON writer + */ + def jsonValueJsonWriter = { + new JsonWriter[JsonValue] { + override def write(value: JsonValue): JsonValue = value + } + } +} diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonStringDeserializer.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonStringDeserializer.scala new file mode 100644 index 0000000..bd55082 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonStringDeserializer.scala @@ -0,0 +1,17 @@ +package org.janjaali.sprayjwt.json + +/** Provides a JSON string deserializer. + */ +trait JsonStringDeserializer: + + /** Gives a JSON string deserializer. + */ + given (String => JsonValue) with + def apply(jsonText: String): JsonValue = deserialize(jsonText) + + /** Deserializes a JSON string as JsonValue. + * + * @param jsonText JSON string that should be deserialized + * @return json value + */ + def deserialize(jsonText: String): JsonValue diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonStringSerializer.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonStringSerializer.scala new file mode 100644 index 0000000..0382adb --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonStringSerializer.scala @@ -0,0 +1,16 @@ +package org.janjaali.sprayjwt.json + +/** Provides a JSON string serializer. + */ +trait JsonStringSerializer: + + /** Gives a JSON string serializer. + */ + given (JsonValue => String) with + def apply(json: JsonValue): String = serialize(json) + + /** Serializes a JSON value as a string. + * + * @param json JSON value that should be serialized + */ + def serialize(json: JsonValue): String diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonValue.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonValue.scala new file mode 100644 index 0000000..6bf2f80 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonValue.scala @@ -0,0 +1,54 @@ +package org.janjaali.sprayjwt.json + +/** Represents a JSON value. + */ +sealed trait JsonValue + +/** Represents a JSON object consisting of a set of members (i.e. name -> JSON + * value pairs). + * + * @param members set of name -> JSON value pairs + */ +final case class JsonObject(members: Map[String, JsonValue]) extends JsonValue + +/** JSON object auxillary constructors and methods. + */ +object JsonObject: + + /** Constructs an empty JSON object. */ + lazy val empty: JsonObject = JsonObject(Map.empty) + +/** Represents a JSON array consisting of a set of elements (i.e. JSON values). + * + * @param elements set of JSON values + */ +final case class JsonArray(elements: Seq[JsonValue]) extends JsonValue + +/** JSON array auxillary constructors and methods. + */ +object JsonArray: + + /** Constructs an empty JSON object. */ + lazy val empty: JsonArray = JsonArray(Seq.empty) + +/** Represents a JSON string. + * + * @param value string value + */ +final case class JsonString(value: String) extends JsonValue + +/** Represents a JSON number. + * + * @param value number value + */ +final case class JsonNumber(value: BigDecimal) extends JsonValue + +/** Represents a JSON boolean. + * + * @param value boolean value + */ +final case class JsonBoolean(value: Boolean) extends JsonValue + +/** Represents a JSON null value. + */ +case object JsonNull extends JsonValue diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonWriter.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonWriter.scala new file mode 100644 index 0000000..33054f8 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/json/JsonWriter.scala @@ -0,0 +1,13 @@ +package org.janjaali.sprayjwt.json + +/** Provides a JSON writer for a given type T. + */ +trait JsonWriter[T] { + + /** Writes a given value as JSON value. + * + * @param value value that should be written as JSON value + * @return JSON value + */ + def write(value: T): JsonValue +} diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/Header.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/Header.scala new file mode 100644 index 0000000..d93a643 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/Header.scala @@ -0,0 +1,170 @@ +package org.janjaali.sprayjwt.jws + +import org.janjaali.sprayjwt.algorithms +import org.janjaali.sprayjwt.algorithms.Algorithms +import org.janjaali.sprayjwt.json._ + +/** Represents a Header. + */ +trait Header { + + /** Type of this header. + */ + protected type T + + /** Json writer for this header's value. + */ + protected def valueJsonWriter: JsonWriter[T] + + /** Name fo this header. + * + * @return header name + */ + def name: String + + /** Value of this header. + * + * @return header value + */ + def value: T + + /** JSON representation of this header's value. + * + * @return JSON value + */ + private[sprayjwt] def valueAsJson: JsonValue = { + valueJsonWriter.write(this.value) + } +} + +/** Provide headers. + */ +object Header { + + /** Identifies the cryptographic algorithm used to secure the JWS + * + * @param value algorithm + */ + final case class Algorithm( + value: algorithms.Algorithm + ) extends Header { + + override def name: String = Algorithm.name + + override type T = algorithms.Algorithm + + override protected def valueJsonWriter: JsonWriter[algorithms.Algorithm] = { + Algorithm.valueJsonWriter + } + } + + object Algorithm { + + val name: String = "alg" + + private def valueJsonWriter: JsonWriter[algorithms.Algorithm] = { + new JsonWriter[algorithms.Algorithm] { + override def write(algorithm: algorithms.Algorithm): JsonValue = + algorithm match + case Algorithms.Rs256 => JsonString("RS256") + case Algorithms.Rs384 => JsonString("RS384") + case Algorithms.Rs512 => JsonString("RS512") + case Algorithms.Hs256 => JsonString("HS256") + case Algorithms.Hs384 => JsonString("HS384") + case Algorithms.Hs512 => JsonString("HS512") + + } + } + + private[Header] def apply(algorithmName: String): Option[Algorithm] = + algorithmName match + case "RS256" => Some(Algorithm(Algorithms.Rs256)) + case "RS384" => Some(Algorithm(Algorithms.Rs384)) + case "RS512" => Some(Algorithm(Algorithms.Rs512)) + case "HS256" => Some(Algorithm(Algorithms.Hs256)) + case "HS384" => Some(Algorithm(Algorithms.Hs384)) + case "HS512" => Some(Algorithm(Algorithms.Hs512)) + case _ => None + } + + /** Declares the media type of the complete JWS. + * + * @param value type + */ + final case class Type( + value: Type.Value + ) extends Header { + + override def name: String = Type.name + + override type T = Type.Value + + override protected def valueJsonWriter: JsonWriter[Type.Value] = { + Type.valueJsonWriter + } + } + + /** Provides type values. + */ + object Type { + + val name: String = "typ" + + sealed trait Value + + object Value { + + /** Type value JWT. + */ + case object Jwt extends Value + } + + private def valueJsonWriter: JsonWriter[Type.Value] = { + new JsonWriter[Type.Value] { + override def write(value: Type.Value): JsonValue = { + value match { + case Type.Value.Jwt => JsonString("JWT") + } + } + } + } + + private[Header] def apply(typeValue: String): Option[Type] = { + typeValue match { + case "JWT" => Some(Type(Type.Value.Jwt)) + case _ => None + } + } + } + + /** Represents a private header that producer and consumer of a JWT can + * agree to use freely unlike registered headers. + * + * @param name name of this header + * @param value value of this header + */ + case class Private[H: JsonWriter](name: String, value: H) extends Header { + override type T = H + + override protected def valueJsonWriter: JsonWriter[H] = { + implicitly[JsonWriter[T]] + } + } + + // TODO: Add docs. + + def apply(name: String, value: JsonValue): Header = { + + import CommonJsonWriters.Implicits.jsonValueJsonWriter + + (name, value) match { + case (Algorithm.name, JsonString(algorithmName)) + if Algorithm(algorithmName).isDefined => + Algorithm(algorithmName).get + case (Type.name, JsonString(typeValue)) if Type(typeValue).isDefined => + Type(typeValue).get + case (name, value) => + Private(name, value) + } + } +} diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/JoseHeader.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/JoseHeader.scala new file mode 100644 index 0000000..5bb4698 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/JoseHeader.scala @@ -0,0 +1,64 @@ +package org.janjaali.sprayjwt.jws + +import org.janjaali.sprayjwt.util.CollectionsFactory +import org.janjaali.sprayjwt.json.JsonObject +import org.janjaali.sprayjwt.json.JsonValue +import org.janjaali.sprayjwt.json.JsonString +import org.janjaali.sprayjwt.json.JsonNumber +import org.janjaali.sprayjwt.json.JsonBoolean + +/** Javascript Object Signing and Encryption (JOSE Header) that contains + * the parameter that describes the cryptographic operations and + * parameters employed to JWS Protected Header's and a JWS Payload. + * + * @param headers set of contained headers + */ +sealed abstract case class JoseHeader private (headers: Set[Header]) { + + // TODO: Doc. + def asJson: JsonObject = { + JsonObject( + headers.map { header => + header.name -> header.valueAsJson + }.toMap + ) + } +} + +// TODO: Doc. +object JoseHeader { + + /** Constructs a JOSE Header for a sequence of headers. + * + * When multiple headers share the same name the later one in the given list + * of headers remains in the resulting headers list. + * + * @param headers set of headers that should be added + * @return JOSE Header + */ + def apply( + headers: Seq[Header] + ): JoseHeader = { + + val uniquelyNamedHeaders = { + CollectionsFactory.uniqueElements(headers)(_.name) + } + + new JoseHeader(uniquelyNamedHeaders.toSet) {} + } + + def apply( + json: JsonObject + ): JoseHeader = { + + val headers = { + json.members.map { case (name, value) => + Header(name, value) + }.toList + } + + JoseHeader(headers) + } + + sealed trait DeserializationFailure +} diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/JwsPayload.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/JwsPayload.scala new file mode 100644 index 0000000..90d5960 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/JwsPayload.scala @@ -0,0 +1,23 @@ +package org.janjaali.sprayjwt.jws + +import org.janjaali.sprayjwt.json.JsonObject +import org.janjaali.sprayjwt.jwt.JwtClaimsSet + +/** Payload that consists of the Claims Set that has to be secured. + * + * @param claimsSet claims set that has to be secured + */ +final case class JwsPayload(claimsSet: JwtClaimsSet) { + + /** JSON representation of this JWS Payload. + * + * @return JSON object + */ + def asJson: JsonObject = { + JsonObject( + claimsSet.claims.map { claim => + claim.name -> claim.valueAsJson + }.toMap + ) + } +} diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/JwsSignature.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/JwsSignature.scala new file mode 100644 index 0000000..5f462dc --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jws/JwsSignature.scala @@ -0,0 +1,5 @@ +package org.janjaali.sprayjwt.jws + +/** Digital signature or MAC over the JWS Protected Header and the JWS Payload. + */ +final case class JwsSignature(value: String) extends AnyVal diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jwt/Claim.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jwt/Claim.scala new file mode 100644 index 0000000..0e3441e --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jwt/Claim.scala @@ -0,0 +1,70 @@ +package org.janjaali.sprayjwt.jwt + +import org.janjaali.sprayjwt.json.{JsonValue, JsonWriter} + +/** A claim consists of a name-value pair that provide information about a token + * subject. + */ +trait Claim { + + /** Type of this claim. + */ + protected type T + + /** Json writer for this claim's value. + */ + protected def valueJsonWriter: JsonWriter[T] + + /** Name of this claim. + * + * @return claim name + */ + def name: String + + /** Value of this claim. + * + * @return claim value + */ + def value: T + + /** JSON representation of this claim's value. + * + * @return JSON value + */ + private[sprayjwt] def valueAsJson: JsonValue = { + valueJsonWriter.write(this.value) + } +} + +object Claim { + + /** The "exp" (expiration time) claim identifies the expiration time on or + * after which the JWT MUST NOT be accepted for processing. + * + * @param value seconds since the epoch + */ + final case class ExpirationTime(value: NumericDate) extends Claim { + + override type T = NumericDate + + override def valueJsonWriter: JsonWriter[T] = NumericDate.jsonWriter + + override def name: String = "exp" + } + + /** Represents a private claim that producer and consumer of a JWT can agree + * to use freely unlike registered or public claims. + * + * @param name name of this claim + * @param value value of this claim + */ + final case class Private[C: JsonWriter]( + name: String, + value: C + ) extends Claim { + + override type T = C + + override def valueJsonWriter: JsonWriter[T] = implicitly[JsonWriter[T]] + } +} diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jwt/ClaimsSet.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jwt/ClaimsSet.scala new file mode 100644 index 0000000..2a75454 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jwt/ClaimsSet.scala @@ -0,0 +1,31 @@ +package org.janjaali.sprayjwt.jwt + +import org.janjaali.sprayjwt.util.CollectionsFactory + +/** Claims Set represents a data structure whose members are the claims. + * + * The claim names within a Claims Set are unique. + * + * @param claims set of uniquely named claims + */ +sealed abstract case class JwtClaimsSet private (claims: Set[Claim]) + +object JwtClaimsSet { + + /** Constructs a Claims Set for a set of uniquely named claims. + * + * When multiple claims share the same name the later one in the given list + * of claims remains in the resulting claims set. + * + * @param claims claims that should be added to the Claims Set. + * @return Claims Set + */ + def apply(claims: Seq[Claim]): JwtClaimsSet = { + + val claimsWithUniqueNames = { + CollectionsFactory.uniqueElements(claims)(_.name) + } + + new JwtClaimsSet(claimsWithUniqueNames.toSet) {} + } +} diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jwt/NumericDate.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jwt/NumericDate.scala new file mode 100644 index 0000000..1b4e540 --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/jwt/NumericDate.scala @@ -0,0 +1,25 @@ +package org.janjaali.sprayjwt.jwt + +import org.janjaali.sprayjwt.json.{CommonJsonWriters, JsonWriter} + +/** A JSON numeric value representing the number of seconds from + * 1970-01-01T00:00:00Z UTC until the specified UTC date/time, ignoring leap + * seconds. + * + * @param value seconds since the epoch + */ +final case class NumericDate(secondsSinceEpoch: Long) + +/** Companion object for Numeric Date. + */ +object NumericDate { + + /** JSON writer for Numeric Date. + */ + val jsonWriter: JsonWriter[NumericDate] = { + + import CommonJsonWriters.Implicits.longJsonWriter + + CommonJsonWriters.flatValueJsonWriter + } +} diff --git a/spray-jwt/src/main/scala/org/janjaali/sprayjwt/util/CollectionsFactory.scala b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/util/CollectionsFactory.scala new file mode 100644 index 0000000..ddc02eb --- /dev/null +++ b/spray-jwt/src/main/scala/org/janjaali/sprayjwt/util/CollectionsFactory.scala @@ -0,0 +1,36 @@ +package org.janjaali.sprayjwt.util + +/** Utilities methods to setup collections with given properties. + */ +object CollectionsFactory { + + /** Creates a collection from a given collecting by ensuring that the given + * predicate uniquely matches for at most one element of the returned + * collection. + * + * When multiple elements match the same predicate the later one in the + * sequence remains in the resulting sequence. + * + * Example: + * + *
+    * val origin = List(("a", 1), ("b", 2), ("c", 3), ("b", 4), ("a", 5))
+    * val result = uniqueElements(origin) { elem => elem._1 }
+    *
+    * result // => List((c,3), (b,4), (a,5))
+    * 
+ * + * @param collection + * @param predicate + * @return sequence containing at most one element that matches the predicate + */ + def uniqueElements[T, U](collection: Seq[T])(predicate: T => U): Seq[T] = { + collection.foldRight(List.empty[T]) { case (elem, collection) => + if (collection.map(predicate).contains(predicate(elem))) { + collection + } else { + elem :: collection + } + } + } +} diff --git a/src/test/resources/org/janjaali/sprayjwt/test.rsa.private.key b/spray-jwt/src/test/resources/org/janjaali/sprayjwt/test.rsa.private.key similarity index 100% rename from src/test/resources/org/janjaali/sprayjwt/test.rsa.private.key rename to spray-jwt/src/test/resources/org/janjaali/sprayjwt/test.rsa.private.key diff --git a/src/test/resources/org/janjaali/sprayjwt/test.rsa.public.key b/spray-jwt/src/test/resources/org/janjaali/sprayjwt/test.rsa.public.key similarity index 100% rename from src/test/resources/org/janjaali/sprayjwt/test.rsa.public.key rename to spray-jwt/src/test/resources/org/janjaali/sprayjwt/test.rsa.public.key diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/algorithms/JsonSupportSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/algorithms/JsonSupportSpec.scala new file mode 100644 index 0000000..c6f9c84 --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/algorithms/JsonSupportSpec.scala @@ -0,0 +1,118 @@ +package org.janjaali.sprayjwt.algorithms + +import org.janjaali.sprayjwt.encoder.{Base64UrlDecoder, Base64UrlEncoder} +import org.janjaali.sprayjwt.json.CommonJsonWriters.Implicits._ +import org.janjaali.sprayjwt.json.{ + JsonStringDeserializer, + JsonStringSerializer, + JsonValue +} +import org.janjaali.sprayjwt.jws.{Header, JoseHeader, JwsPayload, JwsSignature} +import org.janjaali.sprayjwt.jwt.{Claim, JwtClaimsSet, NumericDate} +import org.janjaali.sprayjwt.tests.ScalaTestSpec + +trait JsonSupportSpec extends ScalaTestSpec: + + protected given jsonStringSerializer: JsonStringSerializer + + protected given jsonStringDeserializer: JsonStringDeserializer + + protected def verifySignWithHmacAlgorithms(): Unit = + verifySignWithHmac256Algorithm() + verifySignWithHmac384Algorithm() + verifySignWithHmac512Algorithm() + + protected def verifyValidationWithHmacAlgorithms(): Unit = + verifyValidationWithHmac256Algorithm() + verifyValidationWithHmac384Algorithm() + verifyValidationWithHmac512Algorithm() + + private def verifySignWithHmac256Algorithm(): Unit = + verifySignWithAlgorithm( + algorithm = Algorithms.Hs256, + expectedSignature = JwsSignature( + "jUzTJEnlFeTXDUPp9vJMwoalvXJ55IZ6DaBExN08UtA" + ) + ) + + private def verifySignWithHmac384Algorithm(): Unit = + verifySignWithAlgorithm( + algorithm = Algorithms.Hs384, + expectedSignature = JwsSignature( + "tz6NV8IfhPNqEnfUgeu0TJowwvWsjcmFCiRC_F-7bTOQeUle8jomj151nYHx1-IQ" + ) + ) + + private def verifySignWithHmac512Algorithm(): Unit = + verifySignWithAlgorithm( + algorithm = Algorithms.Hs512, + expectedSignature = JwsSignature( + "dOf7rSkv-y62jQDwAuzNdNKX2jfYK2HREBYqlB0rLnlERIlWkQ4BkVbbVyGi47br1Os4FllE4yjuz_FVjabK5w" + ) + ) + + private def verifySignWithAlgorithm( + algorithm: Algorithm, + expectedSignature: JwsSignature + ): Unit = + s"Verify serializer with algorithm '${algorithm}'." in { + val joseHeader = JoseHeader( + Seq( + Header.Type(Header.Type.Value.Jwt), + // TODO: That's weird, how can we set the algorithm and then just + // sign with another one? + Header.Algorithm(algorithm) + ) + ) + + val jwsPayload = JwsPayload( + JwtClaimsSet( + Seq( + // TODO: Maybe add an iss type? + Claim.Private(name = "iss", value = "joe"), + Claim.ExpirationTime(NumericDate(1300819380L)), + Claim.Private(name = "http://example.com/is_root", value = true) + ) + ) + ) + + val secret = Secret("secret value") + + algorithm.sign(joseHeader, jwsPayload, secret) shouldBe expectedSignature + } + + private def verifyValidationWithHmac256Algorithm(): Unit = + verifyValidationWithHmacAlgorithm( + algorithmName = "Hmac256", + data = { + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.eMS0OOcs-0Eo5x5vDepYOcITeG4NtPrtE8seTsT1RT0" + }, + secret = Secret("secret value") + ) + + private def verifyValidationWithHmac384Algorithm(): Unit = + verifyValidationWithHmacAlgorithm( + algorithmName = "Hmac384", + data = { + "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.tXvOFupOdp6ISki7ysqn_gO9LHk2n35d0f_E26d9FYfhLLHNudoWU2HXD_6Tnm7X" + }, + secret = Secret("secret value") + ) + + private def verifyValidationWithHmac512Algorithm(): Unit = + verifyValidationWithHmacAlgorithm( + algorithmName = "Hmac512", + data = { + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.mV13yTaWA-6cQWcQEmUOh2qaTnpF5hQpNNkIMY6RIlbyQQQ-MbE9zjQ19dTQpQ2hxEql5ObbmDmWzqx13ka6Iw", + }, + secret = Secret("secret value") + ) + + private def verifyValidationWithHmacAlgorithm( + algorithmName: String, + data: String, + secret: Secret + ): Unit = + s"Verify deserializer with '$algorithmName'." in { + Algorithms.validate(data, secret) shouldBe true + } diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/algorithms/ScalaCheckGenerators.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/algorithms/ScalaCheckGenerators.scala new file mode 100644 index 0000000..cb5e96e --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/algorithms/ScalaCheckGenerators.scala @@ -0,0 +1,17 @@ +package org.janjaali.sprayjwt.algorithms + +import org.scalacheck.Gen + +object ScalaCheckGenerators { + + def algorithmGen: Gen[Algorithm] = { + Gen.oneOf( + Algorithms.Hs256, + Algorithms.Hs384, + Algorithms.Hs512, + Algorithms.Rs256, + Algorithms.Rs384, + Algorithms.Rs512 + ) + } +} diff --git a/src/test/scala/org/janjaali/sprayjwt/encoder/Base64DecoderSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/encoder/Base64DecoderSpec.scala similarity index 57% rename from src/test/scala/org/janjaali/sprayjwt/encoder/Base64DecoderSpec.scala rename to spray-jwt/src/test/scala/org/janjaali/sprayjwt/encoder/Base64DecoderSpec.scala index 0f7d229..6609daa 100644 --- a/src/test/scala/org/janjaali/sprayjwt/encoder/Base64DecoderSpec.scala +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/encoder/Base64DecoderSpec.scala @@ -1,12 +1,12 @@ package org.janjaali.sprayjwt.encoder -import org.scalatest.FunSpec +import org.scalatest.funspec.AnyFunSpec -class Base64DecoderSpec extends FunSpec { +class Base64DecoderSpec extends AnyFunSpec { describe("Base64Decoder") { it("decodes text as Base64 decoded byte-array") { - val decodedByteArray = Base64Decoder.decode("ZGFuY2U=") + val decodedByteArray = Base64UrlDecoder.decode("ZGFuY2U=") assert(decodedByteArray sameElements "dance".getBytes) } } diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/encoder/Base64UrlEncoderSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/encoder/Base64UrlEncoderSpec.scala new file mode 100644 index 0000000..fb1df8d --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/encoder/Base64UrlEncoderSpec.scala @@ -0,0 +1,37 @@ +package org.janjaali.sprayjwt.encoder + +import org.janjaali.sprayjwt.tests.ScalaTestSpec + +final class Base64UrlUrlEncoderSpec extends ScalaTestSpec { + + "Base64UrlEncoder" - { + + val sut = Base64UrlEncoder + + "encodes text as Base64 URL encoded String." in { + + val text = "{\"typ\":\"JWT\",\r\n \"alg\":\"HS256\"}" + + sut.encode(text) shouldBe "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9" + } + + "encodes text as Base64 URL encoded String without padding." in { + + val text = { + "{\"iss\":\"joe\",\r\n \"exp\":1300819380,\r\n \"http://example.com/is_root\":true}" + } + + sut.encode(text) shouldBe { + "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly" + + "9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ" + } + } + + "encoded byte array as Base64 URL encoded String." in { + + val text = "{\"typ\":\"JWT\",\r\n \"alg\":\"HS256\"}".getBytes("UTF-8") + + sut.encode(text) shouldBe "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9" + } + } +} diff --git a/src/test/scala/org/janjaali/sprayjwt/encoder/ByteEncoderSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/encoder/ByteEncoderSpec.scala similarity index 75% rename from src/test/scala/org/janjaali/sprayjwt/encoder/ByteEncoderSpec.scala rename to spray-jwt/src/test/scala/org/janjaali/sprayjwt/encoder/ByteEncoderSpec.scala index dadd364..e2e7015 100644 --- a/src/test/scala/org/janjaali/sprayjwt/encoder/ByteEncoderSpec.scala +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/encoder/ByteEncoderSpec.scala @@ -1,8 +1,8 @@ package org.janjaali.sprayjwt.encoder -import org.scalatest.FunSpec +import org.scalatest.funspec.AnyFunSpec -class ByteEncoderSpec extends FunSpec { +class ByteEncoderSpec extends AnyFunSpec { describe("ByteEncoder") { it("encodes text as byte array") { diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/headers/JwtHeaderJsonProtocolSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/headers/JwtHeaderJsonProtocolSpec.scala new file mode 100644 index 0000000..dc0afe1 --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/headers/JwtHeaderJsonProtocolSpec.scala @@ -0,0 +1,21 @@ +package org.janjaali.sprayjwt.headers + +import org.scalatest.funspec.AnyFunSpec +import spray.json._ +import org.janjaali.sprayjwt.algorithms.Algorithms + +class JwtHeaderJsonProtocolSpec extends AnyFunSpec { + + describe("JwtHeaderJsonProtocol trait") { + it("converts JWT-Header to JsValue") { + val jwtHeaderJson = JwtHeader(Algorithms.Hs256).toJson + assert( + jwtHeaderJson == JsObject( + "typ" -> JsString("JWT"), + "alg" -> JsString("HS256") + ) + ) + } + } + +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/json/CommonJsonWritersSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/json/CommonJsonWritersSpec.scala new file mode 100644 index 0000000..b3543de --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/json/CommonJsonWritersSpec.scala @@ -0,0 +1,48 @@ +package org.janjaali.sprayjwt.json + +import org.janjaali.sprayjwt.tests.ScalaTestSpec + +class CommonJsonWritersSpec extends ScalaTestSpec { + + private val sut = CommonJsonWriters + + "Int JsonWriter" - { + + "should write Int values as JsonNumber." in { + + sut.intJsonWriter.write(2) shouldBe JsonNumber(2) + } + } + + "Long JsonWriter" - { + + "should write Long values as JsonNumber." in { + + sut.longJsonWriter.write(41L) shouldBe JsonNumber(41L) + } + } + + "String JsonWriter" - { + + "should write String values as JsonString." in { + + sut.stringJsonWriter.write("string") shouldBe JsonString("string") + } + } + + "Flat value JSON writer" - { + + "should write flat JSON values for products with at least an arbitrary of 1." in { + + import CommonJsonWriters.Implicits.stringJsonWriter + + case class Prod(value: String) + + val prodExample = Prod("dance") + + sut.flatValueJsonWriter[Prod, String].write(prodExample) shouldBe { + JsonString("dance") + } + } + } +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/HeaderSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/HeaderSpec.scala new file mode 100644 index 0000000..605f7aa --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/HeaderSpec.scala @@ -0,0 +1,153 @@ +package org.janjaali.sprayjwt.jws + +import org.janjaali.sprayjwt.algorithms +import org.janjaali.sprayjwt.algorithms.Algorithms +import org.janjaali.sprayjwt.json.CommonJsonWriters.Implicits.jsonValueJsonWriter +import org.janjaali.sprayjwt.json.{JsonBoolean, JsonString, JsonValue} +import org.janjaali.sprayjwt.tests.ScalaCheckGeneratorsSampler._ +import org.janjaali.sprayjwt.tests.ScalaTestSpec +import org.scalacheck.Gen +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +class HeaderSpec extends ScalaTestSpec with ScalaCheckDrivenPropertyChecks { + + "Algorithm header" - { + + "when serialized as JSON value" - { + + "should result in a JSON string." in { + + forAll(ScalaCheckGenerators.algorithmHeaderGen) { header => + header.valueAsJson shouldBe a[JsonString] + } + } + } + } + + "Type header" - { + + "when serialized as JSON value" - { + + "should result in a JSON string." in { + + forAll(ScalaCheckGenerators.typeHeaderGen) { header => + header.valueAsJson shouldBe a[JsonString] + } + } + } + } + + "Header" - { + + "when constructed from a header name and value" - { + + "when header name is 'alg'" - { + + val headerName = "alg" + + "when value matches an 'RS256'" - { + + behave like createAlgorithmHeader( + value = JsonString("RS256"), + expectedHeader = Header.Algorithm(Algorithms.Rs256) + ) + } + + "when value matches an 'RS384'" - { + + behave like createAlgorithmHeader( + value = JsonString("RS384"), + expectedHeader = Header.Algorithm(Algorithms.Rs384) + ) + } + + "when value matches an 'RS512'" - { + + behave like createAlgorithmHeader( + value = JsonString("RS512"), + expectedHeader = Header.Algorithm(Algorithms.Rs512) + ) + } + + "when value matches an 'HS256'" - { + + behave like createAlgorithmHeader( + value = JsonString("HS256"), + expectedHeader = Header.Algorithm(Algorithms.Hs256) + ) + } + + "when value matches an 'HS384'" - { + + behave like createAlgorithmHeader( + value = JsonString("HS384"), + expectedHeader = Header.Algorithm(Algorithms.Hs384) + ) + } + + "when value matches an 'HS512'" - { + + behave like createAlgorithmHeader( + value = JsonString("HS512"), + expectedHeader = Header.Algorithm(Algorithms.Hs512) + ) + } + + def createAlgorithmHeader( + value: JsonString, + expectedHeader: Header.Algorithm + ): Unit = { + + s"should create $expectedHeader." in { + Header(headerName, value) shouldBe expectedHeader + } + } + + "when value does not match an algorithm" - { + + "should create a private header." in { + + Header(headerName, JsonBoolean(false)) shouldBe { + Header.Private[JsonValue](headerName, JsonBoolean(false)) + } + } + } + } + + "when header name is 'typ'" - { + + val headerName = "typ" + + "when value matches a type value" - { + + "should create a type header." in { + + Header(headerName, JsonString("JWT")) shouldBe { + Header.Type(Header.Type.Value.Jwt) + } + } + } + + "when value does not match a type value" - { + + "should create a private header." in { + + Header(headerName, JsonBoolean(false)) shouldBe { + Header.Private[JsonValue](headerName, JsonBoolean(false)) + } + } + } + } + + "when header name does not match 'alg' nor 'typ'" - { + + "should create a private header." in { + + Header("arbitrary", JsonBoolean(false)) shouldBe { + Header.Private[JsonValue]("arbitrary", JsonBoolean(false)) + } + } + } + } + } +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/JoseHeaderSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/JoseHeaderSpec.scala new file mode 100644 index 0000000..4d5c255 --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/JoseHeaderSpec.scala @@ -0,0 +1,140 @@ +package org.janjaali.sprayjwt.jws + +import org.janjaali.sprayjwt.algorithms.Algorithm +import org.janjaali.sprayjwt.json.{JsonNumber, JsonObject} +import org.janjaali.sprayjwt.tests.{ScalaCheckGeneratorsSampler, ScalaTestSpec} +import org.scalacheck.Gen +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks +import org.janjaali.sprayjwt.json.JsonValue + +class JoseHeaderSpec extends ScalaTestSpec with ScalaCheckDrivenPropertyChecks { + + "Jose Header" - { + + "when constructed from a sequence of headers" - { + + "should not contain headers with the same name." in { + + forAll(ScalaCheckGenerators.headersGen) { headers => + val headerNames = JoseHeader(headers).headers.map(_.name) + + headerNames.toSet.size shouldBe headerNames.size + } + } + + "should add headers with distinct names." in { + + forAll(ScalaCheckGenerators.headersGen) { headers => + val distinctNamedHeaders = { + headers.groupBy(_.name).values.map(_.head).toSeq + } + + val joseHeader = JoseHeader(distinctNamedHeaders) + + joseHeader.headers should contain theSameElementsAs { + distinctNamedHeaders + } + } + } + + "with headers with the same name" - { + + "should add the later ones." in { + + import org.janjaali.sprayjwt.json.CommonJsonWriters.Implicits._ + + val joseHeaders = JoseHeader( + List(Header.Private("name", 1), Header.Private("name", 3)) + ) + + joseHeaders.headers should contain only Header.Private("name", 3) + } + } + } + + "when constructed from a JSON object" - { + + "should not contain headers with the same name." in { + + forAll(ScalaCheckGenerators.headersGen) { headers => + + val json = { + JsonObject( + headers.map { header => + header.name -> header.valueAsJson + }.toMap + ) + } + + val headerNames = JoseHeader(json).headers.map(_.name) + + headerNames.toSet.size shouldBe headerNames.size + } + } + + "should add headers with distinct names." in { + + forAll(ScalaCheckGenerators.headersGen) { headers => + val distinctNamedHeaders = { + headers.groupBy(_.name).values.map(_.head).toSeq + } + + val json = { + JsonObject( + distinctNamedHeaders.map { header => + header.name -> header.valueAsJson + }.toMap + ) + } + + val joseHeader = JoseHeader(json) + + joseHeader.headers.map(_.name) should contain theSameElementsAs { + distinctNamedHeaders.map(_.name) + } + } + } + + "with headers with the same name" - { + + "should add the later ones." in { + + import org.janjaali.sprayjwt.json.CommonJsonWriters.Implicits._ + + val joseHeaders = JoseHeader( + JsonObject( + Map( + "name" -> JsonNumber(1), + "name" -> JsonNumber(3) + ) + ) + ) + + joseHeaders.headers should contain only { + Header.Private[JsonValue]( + "name", + JsonNumber(3) + ) // TODO: JsonNumber(3) should be 3 - grant implicit to deserialize such JSON values in private headers + } + } + } + } + + "when serialized as JSON" - { + + "should result in a JSON object." in { + + forAll(ScalaCheckGenerators.joseHeader) { joseHeader => + + val jsonObject = joseHeader.asJson + + val expectedMembers = joseHeader.headers.map { header => + header.name -> header.valueAsJson + } + + jsonObject.members should contain theSameElementsAs expectedMembers + } + } + } + } +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/JwsPayloadSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/JwsPayloadSpec.scala new file mode 100644 index 0000000..ca892d0 --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/JwsPayloadSpec.scala @@ -0,0 +1,26 @@ +package org.janjaali.sprayjwt.jws + +import org.janjaali.sprayjwt.tests.ScalaTestSpec +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +class JwsPayloadSpec extends ScalaTestSpec with ScalaCheckDrivenPropertyChecks { + + "JWS payload" - { + + "should serialize claims as JSON object." in { + + forAll(ScalaCheckGenerators.jwsPayloadGen) { jwsPayload => + val jwsPayloadAsJsonObject = jwsPayload.asJson + + val expectedJsonObjectMembers = { + + jwsPayload.claimsSet.claims.map { claim => + claim.name -> claim.valueAsJson + }.toMap + } + + jwsPayloadAsJsonObject.members should contain theSameElementsAs expectedJsonObjectMembers + } + } + } +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/ScalaCheckGenerators.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/ScalaCheckGenerators.scala new file mode 100644 index 0000000..8c10222 --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jws/ScalaCheckGenerators.scala @@ -0,0 +1,54 @@ +package org.janjaali.sprayjwt.jws + +import org.janjaali.sprayjwt.{algorithms, jwt} +import org.scalacheck.{Arbitrary, Gen} +import org.janjaali.sprayjwt.json.JsonWriter + +object ScalaCheckGenerators { + + def algorithmHeaderGen: Gen[Header.Algorithm] = { + algorithms.ScalaCheckGenerators.algorithmGen.map(Header.Algorithm.apply) + } + + def typeHeaderGen: Gen[Header.Type] = { + + import Header.Type + + Type(Type.Value.Jwt) + } + + def privateHeaderGen[T: JsonWriter: Arbitrary]: Gen[ + Header.Private[T] + ] = { + + val valueGen = implicitly[Arbitrary[T]].arbitrary + + for { + name <- Gen.alphaStr + value <- valueGen + } yield Header.Private(name, value) + } + + def headerGen: Gen[Header] = { + + import org.janjaali.sprayjwt.json.CommonJsonWriters.Implicits._ + + Gen.oneOf( + algorithmHeaderGen, + typeHeaderGen, + privateHeaderGen[String] + ) + } + + def headersGen: Gen[List[Header]] = { + Gen.listOf(headerGen) + } + + def joseHeader: Gen[JoseHeader] = { + headersGen.map(JoseHeader.apply) + } + + def jwsPayloadGen: Gen[JwsPayload] = { + jwt.ScalaCheckGenerators.claimsSetGen.map(JwsPayload.apply) + } +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/ClaimSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/ClaimSpec.scala new file mode 100644 index 0000000..3c9c56a --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/ClaimSpec.scala @@ -0,0 +1,18 @@ +package org.janjaali.sprayjwt.jwt + +import org.janjaali.sprayjwt.json.JsonNumber +import org.janjaali.sprayjwt.tests.ScalaTestSpec +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks + +class ClaimSpec extends ScalaTestSpec with ScalaCheckPropertyChecks { + + "Expiration time claim" - { + + "value should be JSON serialized as a JSON number." in { + + forAll(ScalaCheckGenerators.expirationTimeClaimGen) { claim => + claim.valueAsJson shouldBe JsonNumber(claim.value.secondsSinceEpoch) + } + } + } +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/ClaimsSetSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/ClaimsSetSpec.scala new file mode 100644 index 0000000..caabc0c --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/ClaimsSetSpec.scala @@ -0,0 +1,57 @@ +package org.janjaali.sprayjwt.jwt + +import org.janjaali.sprayjwt.tests.ScalaTestSpec +import org.janjaali.sprayjwt.json.CommonJsonWriters.Implicits._ +import org.scalacheck.Gen +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +class ClaimsSetSpec extends ScalaTestSpec with ScalaCheckDrivenPropertyChecks { + + "Claim Set" - { + + "when constructed" - { + + val sut = JwtClaimsSet.apply _ + + "should not contain claims with the same name." in { + + forAll(ScalaCheckGenerators.claimsGen) { claims => + val headerNames = sut(claims).claims.map(_.name) + + headerNames.toSet.size shouldBe headerNames.size + } + } + + "should add all claims with uniqueNames." in { + + forAll(ScalaCheckGenerators.claimsGen) { claims => + val distinctNamedClaims = { + claims.groupBy(_.name).values.map(_.head).toSeq + } + + val claimsSet = sut(distinctNamedClaims) + + claimsSet.claims should contain theSameElementsAs { + distinctNamedClaims + } + } + } + + "with claims with the same name" - { + + "should add the later ones." in { + val claimsSet = sut( + List( + Claim.Private("name", 1), + Claim.Private("name", 4) + ) + ) + + claimsSet.claims should contain only { + Claim.Private("name", 4) + } + } + } + } + } +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/NumericDateSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/NumericDateSpec.scala new file mode 100644 index 0000000..5b12d7e --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/NumericDateSpec.scala @@ -0,0 +1,25 @@ +package org.janjaali.sprayjwt.jwt + +import org.janjaali.sprayjwt.json.JsonNumber +import org.janjaali.sprayjwt.tests.ScalaTestSpec +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +class NumericDateSpec extends ScalaTestSpec with ScalaCheckDrivenPropertyChecks { + + "Numeric date" - { + + "JSON writer" - { + + val sut = NumericDate.jsonWriter + + "should writer numeric dates as JSON number." in { + + forAll(ScalaCheckGenerators.numericDateGen) { numericDate => + sut.write(numericDate) shouldBe { + JsonNumber(numericDate.secondsSinceEpoch) + } + } + } + } + } +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/ScalaCheckGenerators.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/ScalaCheckGenerators.scala new file mode 100644 index 0000000..d25e433 --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/jwt/ScalaCheckGenerators.scala @@ -0,0 +1,65 @@ +package org.janjaali.sprayjwt.jwt + +import org.janjaali.sprayjwt.json.CommonJsonWriters +import org.scalacheck.Gen + +import java.time.Instant + +object ScalaCheckGenerators { + + /** Generator for numeric dates containing epoch seconds in the time frame of: + * [now - 100 years, now + 100 years]. + * + * @return generator for numeric date + */ + def numericDateGen: Gen[NumericDate] = { + + import scala.concurrent.duration._ + + val aHundredYearsInSeconds = (365.days * 10).toSeconds + + val now = Instant.now() + + Gen + .chooseNum( + now.minusSeconds(aHundredYearsInSeconds).getEpochSecond(), + now.plusSeconds(aHundredYearsInSeconds).getEpochSecond() + ) + .map(NumericDate.apply) + } + + /** Generator for an expiration time claim that contains a numeric date time + * in the time interval of: [now - 100 years, now + 100 years]. + * + * @return generator for expiration time + */ + def expirationTimeClaimGen: Gen[Claim.ExpirationTime] = { + numericDateGen.map(Claim.ExpirationTime.apply) + } + + def privateClaimGen: Gen[Claim.Private[_]] = { + + import CommonJsonWriters.Implicits.stringJsonWriter + + for { + name <- Gen.alphaStr + value <- Gen.alphaStr + } yield Claim.Private(name, value) + } + + def claimGen: Gen[Claim] = { + + Gen.oneOf( + expirationTimeClaimGen, + privateClaimGen + ) + } + + def claimsGen: Gen[List[Claim]] = { + Gen.listOf(claimGen) + } + + def claimsSetGen: Gen[JwtClaimsSet] = { + claimsGen.map(JwtClaimsSet.apply) + } +} diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/tests/ScalaCheckGeneratorsSampler.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/tests/ScalaCheckGeneratorsSampler.scala new file mode 100644 index 0000000..2b69c23 --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/tests/ScalaCheckGeneratorsSampler.scala @@ -0,0 +1,23 @@ +package org.janjaali.sprayjwt.tests + +import org.scalacheck.Gen + +/** Provides sampler based on ScalaCheck generators and enables to do the + * following: + * + *
+  * val gen: Gen[T] = ???
+  * val t: T = gen.get
+  * 
+ */ +trait ScalaCheckGeneratorsSampler { + + implicit class Sampler[T](gen: Gen[T]) { + + def get: T = { + gen.sample.get + } + } +} + +object ScalaCheckGeneratorsSampler extends ScalaCheckGeneratorsSampler diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/tests/ScalaTestSpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/tests/ScalaTestSpec.scala new file mode 100644 index 0000000..16d8dc1 --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/tests/ScalaTestSpec.scala @@ -0,0 +1,6 @@ +package org.janjaali.sprayjwt.tests + +import org.scalatest.matchers.should +import org.scalatest.freespec.AnyFreeSpec + +trait ScalaTestSpec extends AnyFreeSpec with should.Matchers diff --git a/spray-jwt/src/test/scala/org/janjaali/sprayjwt/util/CollectionsFactorySpec.scala b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/util/CollectionsFactorySpec.scala new file mode 100644 index 0000000..2fb1330 --- /dev/null +++ b/spray-jwt/src/test/scala/org/janjaali/sprayjwt/util/CollectionsFactorySpec.scala @@ -0,0 +1,38 @@ +package org.janjaali.sprayjwt.util + +import org.janjaali.sprayjwt.tests.ScalaTestSpec + +class CollectionsFactorySpec extends ScalaTestSpec { + + "CollectionsFactory" - { + + val sut = CollectionsFactory + + "should create collections containing at most one element for a given predicate" - { + + "when empty should be empty" in { + + sut.uniqueElements(Nil)(_ => true) shouldBe Nil + } + + "when predicate matches all should contain last matching element." in { + + sut.uniqueElements(List(1, 2, 3))(_ < 10) shouldBe List(3) + } + + "when predicate matches none should contain last none matching element." in { + + sut.uniqueElements(List(1, 2, 3))(_ > 10) shouldBe List(3) + } + + "when predicate matches multiple elements should contain last matching element." in { + + val pairs = List(("a", 1), ("b", 2), ("c", 3), ("b", 4), ("a", 5)) + + sut.uniqueElements(pairs)(elem => elem._1) shouldBe { + List(("c", 3), ("b", 4), ("a", 5)) + } + } + } + } +} diff --git a/src/main/scala/org/janjaali/sprayjwt/Jwt.scala b/src/main/scala/org/janjaali/sprayjwt/Jwt.scala deleted file mode 100644 index 8555b1f..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/Jwt.scala +++ /dev/null @@ -1,172 +0,0 @@ -package org.janjaali.sprayjwt - -import java.security.Security - -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.janjaali.sprayjwt.algorithms.HashingAlgorithm -import org.janjaali.sprayjwt.encoder.{Base64Decoder, Base64Encoder} -import org.janjaali.sprayjwt.exceptions.{InvalidJwtException, InvalidSignatureException} -import org.janjaali.sprayjwt.headers.{JwtHeader, JwtHeaderJsonWriter} -import spray.json._ - -import scala.util.{Success, Try} - -/** - * JWT Encoder/Decoder. - */ -object Jwt { - - addBouncyCastleProvider() - - /** - * Encodes payload as JWT. - * - * @param payload the payload to encode as JWT's payload - * @param secret the secret which is used to sign JWT - * @param algorithm the hashing algorithm used for encoding - * @return encoded JWT - */ - def encode(payload: String, secret: String, algorithm: HashingAlgorithm): Try[String] = { - Try(encode(payload.parseJson, secret, algorithm, None)) - } - - /** - * Encodes payload as JWT. - * - * @param payload the payload to encode as JWT's payload - * @param secret the secret which is used to sign JWT - * @param algorithm the hashing algorithm used for encoding - * @param jwtClaims reserved JWT claims to add to payload (specified claims will overwrite equal named claims in - * payload) - * @return encoded JWT - */ - def encode(payload: String, secret: String, algorithm: HashingAlgorithm, jwtClaims: JwtClaims): Try[String] = { - Try(encode(payload.parseJson, secret, algorithm, Some(jwtClaims))) - } - - /** - * Encodes payload as JWT. - * - * @param payload the payload to encode as JWT's payload - * @param secret the secret which is used to sign JWT - * @param algorithm the hashing algorithm used for encoding - * @return encoded JWT - */ - def encode(payload: JsValue, secret: String, algorithm: HashingAlgorithm): Try[String] = { - Try(encode(payload, secret, algorithm, None)) - } - - /** - * Encodes payload as JWT. - * - * @param payload the payload to encode as JWT's payload - * @param secret the secret which is used to sign JWT - * @param algorithm the hashing algorithm used for encoding - * @param jwtClaims reserved JWT claims to add to payload (specified claims will overwrite equal named claims in - * payload) - * @return encoded JWT - */ - def encode(payload: JsValue, secret: String, algorithm: HashingAlgorithm, jwtClaims: JwtClaims): Try[String] = { - Try(encode(payload, secret, algorithm, Some(jwtClaims))) - } - - /** - * Decodes JWT token as JsValue. - * - * @param token the JWT token to decode - * @param secret the secret to use to validate signature of JWT - * @return JsValue decoded JWT - */ - def decode(token: String, secret: String): Try[JsValue] = { - decodeAsString(token, secret).map(_.parseJson) - } - - /** - * Decodes JWT token as JsValue. - * - * @param token the JWT token to decode - * @param secret the secret to use to validate signature of JWT - * @return JsValue decoded JWT - */ - def decodeAsString(token: String, secret: String): Try[String] = { - val splitToken = token.split("\\.") - if (splitToken.length != 3) { - throw new InvalidJwtException("JWT must have form header.payload.signature") - } - - val header = splitToken(0) - val payload = splitToken(1) - val data = s"$header.$payload" - - val signature = splitToken(2) - - val algorithm = getAlgorithmFromHeader(header) - - if (!algorithm.validate(data, signature, secret)) { - throw new InvalidSignatureException() - } - - val payloadDecoded = Base64Decoder.decodeAsString(payload) - Success(payloadDecoded) - } - - private def getAlgorithmFromHeader(header: String): HashingAlgorithm = { - val headerDecoded = Base64Decoder.decodeAsString(header) - val jwtHeader = headerDecoded.parseJson.convertTo[JwtHeader] - jwtHeader.algorithm - } - - private def encode(payload: JsValue, secret: String, algorithm: HashingAlgorithm, jwtClaims: Option[JwtClaims]): String = { - val fields = payload.asJsObject.fields - val reversedClaims = jwtClaims.map(getReversedClaims).getOrElse(Map.empty) - - val payloadWithReservedClaims = JsObject(fields ++ reversedClaims) - - val encodedHeader = getEncodedHeader(algorithm) - val encodedPayload = Base64Encoder.encode(payloadWithReservedClaims.toString) - - val encodedData = s"$encodedHeader.$encodedPayload" - - val signature = algorithm.sign(encodedData, secret) - s"$encodedData.$signature" - } - - private def addBouncyCastleProvider(): Unit = { - if (Security.getProvider("BC") == null) { - Security.addProvider(new BouncyCastleProvider) - } - } - - private def getEncodedHeader(algorithm: HashingAlgorithm): String = { - val header = JwtHeader(algorithm).toJson.toString - Base64Encoder.encode(header) - } - - private def getReversedClaims(jwtClaims: JwtClaims): Map[String, JsValue] = { - Seq( - "iss" -> jwtClaims.iss, - "sub" -> jwtClaims.sub, - "aud" -> jwtClaims.aud, - "exp" -> jwtClaims.exp, - "nbf" -> jwtClaims.nbf, - "isa" -> jwtClaims.isa, - "iat" -> jwtClaims.iat, - "jti" -> jwtClaims.jti - ).filter(_._2.nonEmpty) - .map(entry => entry._1 -> entry._2.get) - .map { - case (name, value: String) => name -> JsString(value) - case (name, value: Long) => name -> JsNumber(value) - case (name, values: Set[_]) => - if (values.size == 1) { - name -> JsString(values.head.asInstanceOf[String]) - } else { - name -> JsArray(values.map(v => JsString(v.asInstanceOf[String])).toVector) - } - - case (name, _) => throw new SerializationException(s"Cannot serialize reserved claim: $name") - } - .toMap - } - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/JwtClaims.scala b/src/main/scala/org/janjaali/sprayjwt/JwtClaims.scala deleted file mode 100644 index f1a46e6..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/JwtClaims.scala +++ /dev/null @@ -1,12 +0,0 @@ -package org.janjaali.sprayjwt - -case class JwtClaims( - iss: Option[String] = None, - sub: Option[String] = None, - aud: Option[Set[String]] = None, - exp: Option[Long] = None, - nbf: Option[Long] = None, - isa: Option[Long] = None, - iat: Option[Long] = None, - jti: Option[String] = None -) diff --git a/src/main/scala/org/janjaali/sprayjwt/algorithms/HS256.scala b/src/main/scala/org/janjaali/sprayjwt/algorithms/HS256.scala deleted file mode 100644 index 911a727..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/algorithms/HS256.scala +++ /dev/null @@ -1,10 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -/** - * Represents HS256 hashing algorithm. - */ -case object HS256 extends HmacAlgorithm("HS256") { - - override val cryptoAlgName = "HMACSHA256" - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/algorithms/HS384.scala b/src/main/scala/org/janjaali/sprayjwt/algorithms/HS384.scala deleted file mode 100644 index 687307e..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/algorithms/HS384.scala +++ /dev/null @@ -1,10 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -/** - * Represents HS384 hashing algorithm. - */ -case object HS384 extends HmacAlgorithm("HS384") { - - override val cryptoAlgName = "HMACSHA384" - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/algorithms/HS512.scala b/src/main/scala/org/janjaali/sprayjwt/algorithms/HS512.scala deleted file mode 100644 index c6d1ac0..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/algorithms/HS512.scala +++ /dev/null @@ -1,10 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -/** - * Represents HS512 hashing algorithm. - */ -case object HS512 extends HmacAlgorithm("HS512") { - - override val cryptoAlgName = "HMACSHA512" - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/algorithms/HashingAlgorithm.scala b/src/main/scala/org/janjaali/sprayjwt/algorithms/HashingAlgorithm.scala deleted file mode 100644 index 8a3fa8f..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/algorithms/HashingAlgorithm.scala +++ /dev/null @@ -1,42 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -/** - * Companion object to map Strings as hashing algorithms. - */ -object HashingAlgorithm { - def apply(name: String): Option[HashingAlgorithm] = name match { - case "HS256" => Some(HS256) - case "HS384" => Some(HS384) - case "HS512" => Some(HS512) - case "RS256" => Some(RS256) - case "RS384" => Some(RS384) - case "RS512" => Some(RS512) - case _ => None - } -} - -/** - * Represents hashing algorithms used for JWT's. - * - * @param name the name of the hashing algorithm - */ -private[sprayjwt] abstract class HashingAlgorithm(val name: String) { - /** - * Signs data. - * - * @param data the data to sign - * @param secret the secret to use for signing the data - * @return signed data - */ - def sign(data: String, secret: String): String - - /** - * Validates signature. - * - * @param data the data to validate signature for - * @param signature the signature to validate - * @param secret the secret to use for validation - * @return true if signature is valid, otherwise returns false - */ - def validate(data: String, signature: String, secret: String): Boolean -} diff --git a/src/main/scala/org/janjaali/sprayjwt/algorithms/HmacAlgorithm.scala b/src/main/scala/org/janjaali/sprayjwt/algorithms/HmacAlgorithm.scala deleted file mode 100644 index bcf9b35..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/algorithms/HmacAlgorithm.scala +++ /dev/null @@ -1,38 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -import org.janjaali.sprayjwt.encoder.{Base64Encoder, ByteEncoder} - -/** - * Represents Hmac hashing algorithms. - * - * @param name the name of the hashing algorithm - */ -private[sprayjwt] abstract class HmacAlgorithm(override val name: String) extends HashingAlgorithm(name) { - - private val provider = "SunJCE" - - /** - * Hashing algorithm name used by SunJCE/BouncyCastle. - */ - protected val cryptoAlgName: String - - override def sign(data: String, secret: String): String = { - val secretAsByteArray = ByteEncoder.getBytes(secret) - val secretKey = new SecretKeySpec(secretAsByteArray, cryptoAlgName) - - val dataAsByteArray = ByteEncoder.getBytes(data) - - val mac = Mac.getInstance(cryptoAlgName, provider) - mac.init(secretKey) - val signAsByteArray = mac.doFinal(dataAsByteArray) - Base64Encoder.encode(signAsByteArray) - } - - override def validate(data: String, signature: String, secret: String): Boolean = { - sign(data, secret) == signature - } - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/algorithms/RS256.scala b/src/main/scala/org/janjaali/sprayjwt/algorithms/RS256.scala deleted file mode 100644 index de74f48..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/algorithms/RS256.scala +++ /dev/null @@ -1,10 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -/** - * Represents RS256 hashing algorithm. - */ -case object RS256 extends RsaAlgorithm("RS256") { - - override protected val cryptoAlgName = "SHA256withRSA" - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/algorithms/RS384.scala b/src/main/scala/org/janjaali/sprayjwt/algorithms/RS384.scala deleted file mode 100644 index b66919f..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/algorithms/RS384.scala +++ /dev/null @@ -1,10 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -/** - * Represents RS384 hashing algorithm. - */ -case object RS384 extends RsaAlgorithm("RS384") { - - override protected val cryptoAlgName = "SHA384withRSA" - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/algorithms/RS512.scala b/src/main/scala/org/janjaali/sprayjwt/algorithms/RS512.scala deleted file mode 100644 index 5182fbf..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/algorithms/RS512.scala +++ /dev/null @@ -1,10 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -/** - * Represents RS512 hashing algorithm. - */ -case object RS512 extends RsaAlgorithm("RS512") { - - override protected val cryptoAlgName = "SHA512withRSA" - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/algorithms/RsaAlgorithm.scala b/src/main/scala/org/janjaali/sprayjwt/algorithms/RsaAlgorithm.scala deleted file mode 100644 index ed73711..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/algorithms/RsaAlgorithm.scala +++ /dev/null @@ -1,72 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -import java.io.{IOException, StringReader} -import java.security.{PrivateKey, PublicKey, Signature} - -import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo -import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter -import org.bouncycastle.openssl.{PEMKeyPair, PEMParser} -import org.janjaali.sprayjwt.encoder.{Base64Decoder, Base64Encoder, ByteEncoder} - -/** - * Represents RSA hashing algorithms. - * - * @param name the name of the hashing algorithm - */ -private[sprayjwt] abstract class RsaAlgorithm(override val name: String) extends HashingAlgorithm(name) { - - private val provider = "BC" - - /** - * Hashing algorithm name used by SunJCE/BouncyCastle. - */ - protected val cryptoAlgName: String - - override def sign(data: String, secret: String): String = { - val key = getPrivateKey(secret) - - val dataByteArray = ByteEncoder.getBytes(data) - - val signature = Signature.getInstance(cryptoAlgName, provider) - signature.initSign(key) - signature.update(dataByteArray) - val signatureByteArray = signature.sign - Base64Encoder.encode(signatureByteArray) - } - - override def validate(data: String, signature: String, secret: String): Boolean = { - val key = getPublicKey(secret) - - val dataByteArray = ByteEncoder.getBytes(data) - - val rsaSignature = Signature.getInstance(cryptoAlgName, provider) - rsaSignature.initVerify(key) - rsaSignature.update(dataByteArray) - rsaSignature.verify(Base64Decoder.decode(signature)) - } - - private def getPublicKey(str: String): PublicKey = { - val pemParser = new PEMParser(new StringReader(str)) - val keyPair = pemParser.readObject() - - Option(keyPair) match { - case Some(publicKeyInfo: SubjectPublicKeyInfo) => - val converter = new JcaPEMKeyConverter - converter.getPublicKey(publicKeyInfo) - case _ => throw new IOException(s"Invalid key for $cryptoAlgName") - } - } - - private def getPrivateKey(str: String): PrivateKey = { - val pemParser = new PEMParser(new StringReader(str)) - val keyPair = pemParser.readObject() - - Option(keyPair) match { - case Some(keyPair: PEMKeyPair) => - val converter = new JcaPEMKeyConverter - converter.getKeyPair(keyPair).getPrivate - case _ => throw new IOException(s"Invalid key for $cryptoAlgName") - } - } - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/encoder/Base64Decoder.scala b/src/main/scala/org/janjaali/sprayjwt/encoder/Base64Decoder.scala deleted file mode 100644 index 85c111c..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/encoder/Base64Decoder.scala +++ /dev/null @@ -1,32 +0,0 @@ -package org.janjaali.sprayjwt.encoder - -import java.util.Base64 - -/** - * Base64Decoder utility class. - */ -private[sprayjwt] object Base64Decoder { - - private lazy val base64Decoder: Base64.Decoder = Base64.getDecoder - - /** - * Decodes Base64 encoded text as ByteArray. - * - * @param text the text to decode as ByteArray - * @return Base64 decoded ByteArray - */ - def decode(text: String): Array[Byte] = { - base64Decoder.decode(text) - } - - /** - * Decodes Base64 decoded text as String. - * - * @param text the text to decode as String - * @return Base64 decoded String - */ - def decodeAsString(text: String): String = { - new String(decode(text)) - } - -} diff --git a/src/main/scala/org/janjaali/sprayjwt/encoder/Base64Encoder.scala b/src/main/scala/org/janjaali/sprayjwt/encoder/Base64Encoder.scala deleted file mode 100644 index b32c805..0000000 --- a/src/main/scala/org/janjaali/sprayjwt/encoder/Base64Encoder.scala +++ /dev/null @@ -1,33 +0,0 @@ -package org.janjaali.sprayjwt.encoder - -import java.util.Base64 - -/** - * Base64Encoder utility class. - */ -private[sprayjwt] object Base64Encoder { - - private lazy val base64Encoder: Base64.Encoder = Base64.getEncoder - - /** - * Encodes text to a Base64 encoded String. - * - * @param text the text to encode - * @return Base64 encoded String - */ - def encode(text: String): String = { - val textAsByteArray = ByteEncoder.getBytes(text) - encode(textAsByteArray) - } - - /** - * Encodes a ByteArray as String. - * - * @param byteArray the ByteArray to encode as String - * @return String encoded ByteArray - */ - def encode(byteArray: Array[Byte]): String = { - base64Encoder.encodeToString(byteArray).replaceAll("=", "") - } - -} diff --git a/src/test/scala/org/janjaali/sprayjwt/JwtSpec.scala b/src/test/scala/org/janjaali/sprayjwt/JwtSpec.scala deleted file mode 100644 index 3d4616e..0000000 --- a/src/test/scala/org/janjaali/sprayjwt/JwtSpec.scala +++ /dev/null @@ -1,277 +0,0 @@ -package org.janjaali.sprayjwt - -import org.janjaali.sprayjwt.algorithms._ -import org.scalatest.FunSpec -import spray.json.{JsBoolean, JsObject, JsString} - -class JwtSpec extends FunSpec { - - describe("Jwt encodes the header to JSON") { - describe("HS256") { - val secret = "secret" - - it("encodes as JWT") { - val payload = """{"sub":"1234567890","name":"John Doe","admin":true}""" - val jwt = Jwt.encode(payload, secret, HS256).get - - // scalastyle:off - val expectedJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("encodes as JWT with iss") { - val payload = """{"sub":"1234567890","name":"John Doe","admin":true}""" - val jwt = Jwt.encode(payload, secret, HS256, JwtClaims(iss = Some("issuer"))).get - - // scalastyle:off - val expectedJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlzcyI6Imlzc3VlciJ9.MabLKH7FNuQXlshZe6m054If8TLP5DvwYccl0ejlUVA" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("encodes JsValue as JWT") { - val payload = """{"sub":"1234567890","name":"John Doe","admin":true}""" - val jsValue = JsObject( - "sub" -> JsString("1234567890"), - "name" -> JsString("John Doe"), - "admin" -> JsBoolean(true) - ) - - val jwt = Jwt.encode(jsValue, secret, HS256).get - - // scalastyle:off - val expectedJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("encodes JsValue as JWT with iss") { - val payload = """{"sub":"1234567890","name":"John Doe","admin":true}""" - val jsValue = JsObject( - "sub" -> JsString("1234567890"), - "name" -> JsString("John Doe"), - "admin" -> JsBoolean(true) - ) - - val jwt = Jwt.encode(jsValue, secret, HS256, JwtClaims(iss = Some("issuer"))).get - - // scalastyle:off - val expectedJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlzcyI6Imlzc3VlciJ9.MabLKH7FNuQXlshZe6m054If8TLP5DvwYccl0ejlUVA" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("encodes as JWT with all reserved claims") { - // scalastyle:off - val payload = - """{"sub":"1234567890","name":"John Doe","admin":true}""" - - val jwt = Jwt.encode(payload, secret, HS256, JwtClaims( - iss = Some("issuer"), - sub = Some("subject"), - aud = Some(Set("audience")), - isa = Some(500), - exp = Some(1000), - nbf = Some(2000), - iat = Some(3000), - jti = Some("jwtId") - )).get - - val expectedJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjIwMDAsImFkbWluIjp0cnVlLCJuYW1lIjoiSm9obiBEb2UiLCJqdGkiOiJqd3RJZCIsImV4cCI6MTAwMCwiaXNhIjo1MDAsImlhdCI6MzAwMCwic3ViIjoic3ViamVjdCIsImF1ZCI6ImF1ZGllbmNlIiwiaXNzIjoiaXNzdWVyIn0.2zS7vqKCLPKOlre6LYMMR/dTp41Q9jV5KiEyE9I6JLw" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("decodes JWT as String") { - // scalastyle:off - val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ" - // scalastyle:on - - val decodedPayload = Jwt.decodeAsString(token, secret).get - val expected = """{"sub":"1234567890","name":"John Doe","admin":true}""" - - assert(decodedPayload == expected) - } - - it("decodes JWT as JsValue") { - // scalastyle:off - val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ" - // scalastyle:on - - val json = Jwt.decode(token, secret).get - - val expected = JsObject( - "sub" -> JsString("1234567890"), - "name" -> JsString("John Doe"), - "admin" -> JsBoolean(true) - ) - assert(json == expected) - } - - it("decodes JWT as JsValue with iss claim") { - // scalastyle:off - val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlzcyI6Imlzc3VlciJ9.MabLKH7FNuQXlshZe6m054If8TLP5DvwYccl0ejlUVA" - // scalastyle:on - - val json = Jwt.decode(token, secret).get - - val expected = JsObject( - "sub" -> JsString("1234567890"), - "name" -> JsString("John Doe"), - "admin" -> JsBoolean(true), - "iss" -> JsString("issuer") - ) - assert(json == expected) - } - } - - describe("HS384") { - val secret = "secret" - - it("encodes as JWT") { - val payload = """{"sub":"1234567890","name":"John Doe","admin":true}""" - val jwt = Jwt.encode(payload, secret, HS384).get - - // scalastyle:off - val expectedJwt = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.DtVnCyiYCsCbg8gUP+579IC2GJ7P3CtFw6nfTTPw+0lZUzqgWAo9QIQElyxOpoRm" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("decodes JWT as String") { - // scalastyle:off - val token = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.DtVnCyiYCsCbg8gUP+579IC2GJ7P3CtFw6nfTTPw+0lZUzqgWAo9QIQElyxOpoRm" - // scalastyle:on - - val decodedPayload = Jwt.decodeAsString(token, secret).get - - val expected = """{"sub":"1234567890","name":"John Doe","admin":true}""" - assert(decodedPayload == expected) - } - } - - describe("HS512") { - val secret = "secret" - - it("encodes as JWT") { - val payload = """{"sub":"1234567890","name":"John Doe","admin":true}""" - val jwt = Jwt.encode(payload, secret, HS512).get - - // scalastyle:off - val expectedJwt = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.YI0rUGDq5XdRw8vW2sDLRNFMN8Waol03iSFH8I4iLzuYK7FKHaQYWzPt0BJFGrAmKJ6SjY0mJIMZqNQJFVpkuw" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("decodes JWT as String") { - // scalastyle:off - val token = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.DtVnCyiYCsCbg8gUP+579IC2GJ7P3CtFw6nfTTPw+0lZUzqgWAo9QIQElyxOpoRm" - // scalastyle:on - - val decodedPayload = Jwt.decodeAsString(token, secret).get - - val expected = """{"sub":"1234567890","name":"John Doe","admin":true}""" - assert(decodedPayload == expected) - } - } - - describe("RS256") { - it("encodes as JWT") { - val source = scala.io.Source.fromURL(getClass.getResource("test.rsa.private.key")) - val secret = try source.mkString finally source.close - - val payload = """{"sub":"1234567890","name":"John Doe","admin":true}""" - val jwt = Jwt.encode(payload, secret, RS256).get - - // scalastyle:off - val expectedJwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.CqkpMs2+ttnOcZMjCk3ca2Fw0d3yGBsS5X+eEtuPhYV77ApgRAidZqZvsC1Cs5hqhX6ZTuer0UnCAQ5n4gvyLoaMiMiGqtm+UeHiUKQSeThtqf4M5ylMERi971gZV5ffXPeAHUZUPN8IiMof2BjUwOk4cN7WVfz5i80zcXAkbBUcra2uPlvVpHXGrIVI3CPpBYs4Hn3towNHX9bpWnqfvogy5TXzMEVHAF8H/TgGDwmCMuIGmi4xdlVviXTXrF/znPNNowTuI8aaXenJRYaDkI0VyN6MChmsA8aDOMMSlikDrgGzdxQSGJrBSrvrjnuJMK9raJ7dr/1U+5Rghtms+Q" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("decodes JWT as String") { - val source = scala.io.Source.fromURL(getClass.getResource("test.rsa.public.key")) - val public = try source.mkString finally source.close - - // scalastyle:off - val token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.CqkpMs2+ttnOcZMjCk3ca2Fw0d3yGBsS5X+eEtuPhYV77ApgRAidZqZvsC1Cs5hqhX6ZTuer0UnCAQ5n4gvyLoaMiMiGqtm+UeHiUKQSeThtqf4M5ylMERi971gZV5ffXPeAHUZUPN8IiMof2BjUwOk4cN7WVfz5i80zcXAkbBUcra2uPlvVpHXGrIVI3CPpBYs4Hn3towNHX9bpWnqfvogy5TXzMEVHAF8H/TgGDwmCMuIGmi4xdlVviXTXrF/znPNNowTuI8aaXenJRYaDkI0VyN6MChmsA8aDOMMSlikDrgGzdxQSGJrBSrvrjnuJMK9raJ7dr/1U+5Rghtms+Q" - // scalastyle:on - - val decoded = Jwt.decodeAsString(token, public).get - - val expected = """{"sub":"1234567890","name":"John Doe","admin":true}""" - assert(decoded == expected) - } - } - - describe("RS384") { - it("encodes as JWT") { - val source = scala.io.Source.fromURL(getClass.getResource("test.rsa.private.key")) - val secret = try source.mkString finally source.close - - val payload = """{"sub":"1234567890","name":"John Doe","admin":true}""" - val jwt = Jwt.encode(payload, secret, RS384).get - - // scalastyle:off - val expectedJwt = "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.oCcq/xI4jcjxMB5zRSp6F7bfQjT2KhdH4fJkN3E24wa6ltE2UufgXQ4/wJjOalJ4h0RbnEUwlMzdD3hJgNRU7BfD6r5GzVo/RLTLTkyTD+KsHXYiS4qHYOZ1otyoPFV/QzQcovoOXT+kmsVH/S6mpVzN1Qh1OUgu+2D9swH+6rZi0YctrKv3dXou+GSVt1l5xfyA7R4KB8HwONTwdyEbTSM/aJWP+Ob80kDNAEs9xkx/2KzY1iGfdh0FwIU2OKdc+b0CNlVQrbYwLX55Yk5CBPJY6UNlXwSmCFwlbZvjChkwE5MH4ICDLWN7j2llm6PX38RE2xRCguqId/iA8vwj8g" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("decodes JWT as String") { - val source = scala.io.Source.fromURL(getClass.getResource("test.rsa.public.key")) - val public = try source.mkString finally source.close - - // scalastyle:off - val token = "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.oCcq/xI4jcjxMB5zRSp6F7bfQjT2KhdH4fJkN3E24wa6ltE2UufgXQ4/wJjOalJ4h0RbnEUwlMzdD3hJgNRU7BfD6r5GzVo/RLTLTkyTD+KsHXYiS4qHYOZ1otyoPFV/QzQcovoOXT+kmsVH/S6mpVzN1Qh1OUgu+2D9swH+6rZi0YctrKv3dXou+GSVt1l5xfyA7R4KB8HwONTwdyEbTSM/aJWP+Ob80kDNAEs9xkx/2KzY1iGfdh0FwIU2OKdc+b0CNlVQrbYwLX55Yk5CBPJY6UNlXwSmCFwlbZvjChkwE5MH4ICDLWN7j2llm6PX38RE2xRCguqId/iA8vwj8g" - // scalastyle:on - - val decoded = Jwt.decodeAsString(token, public).get - - val expected = """{"sub":"1234567890","name":"John Doe","admin":true}""" - assert(decoded == expected) - } - } - - describe("RS512") { - it("encodes as JWT") { - val source = scala.io.Source.fromURL(getClass.getResource("test.rsa.private.key")) - val secret = try source.mkString finally source.close - - val payload = """{"sub":"1234567890","name":"John Doe","admin":true}""" - val jwt = Jwt.encode(payload, secret, RS512).get - - // scalastyle:off - val expectedJwt = "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.JuhmKgWohwJS9lLhBo5ealB+RA5CX6VgzYExvUKKL/v5oqEO1bZ91Ayi949jvB5tTgvMOX3njfKpl8tyazfqoHjsXzRvHX/NdUGx1rWhWZ826Zdpgm32fO15Jv1xHbxWbFaqp0zwyLUKPo756lmg1+8IeTBdDvhC7XSlBc9cUDe4x3anltjeUseZllS2PZgQn0pxYXK5KVbAsIasDthprmaJheLBgO+CInCpDiVukUC2WfCGz9tr9IhKwNgLPkcue4uVRubOgV8By68SMZgVdxZXP70siV/sMOqrILyWk7Zi0fSm/JC4QP4fZSenfxwl8FEr4Rs+FWL/clk3fnMYQA" - // scalastyle:on - - assert(jwt == expectedJwt) - } - - it("decodes JWT as String") { - val source = scala.io.Source.fromURL(getClass.getResource("test.rsa.public.key")) - val public = try source.mkString finally source.close - - // scalastyle:off - val token = "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.JuhmKgWohwJS9lLhBo5ealB+RA5CX6VgzYExvUKKL/v5oqEO1bZ91Ayi949jvB5tTgvMOX3njfKpl8tyazfqoHjsXzRvHX/NdUGx1rWhWZ826Zdpgm32fO15Jv1xHbxWbFaqp0zwyLUKPo756lmg1+8IeTBdDvhC7XSlBc9cUDe4x3anltjeUseZllS2PZgQn0pxYXK5KVbAsIasDthprmaJheLBgO+CInCpDiVukUC2WfCGz9tr9IhKwNgLPkcue4uVRubOgV8By68SMZgVdxZXP70siV/sMOqrILyWk7Zi0fSm/JC4QP4fZSenfxwl8FEr4Rs+FWL/clk3fnMYQA" - // scalastyle:on - - val decoded = Jwt.decodeAsString(token, public).get - - val expected = """{"sub":"1234567890","name":"John Doe","admin":true}""" - assert(decoded == expected) - } - } - } - -} diff --git a/src/test/scala/org/janjaali/sprayjwt/algorithms/HashingAlgorithmSpec.scala b/src/test/scala/org/janjaali/sprayjwt/algorithms/HashingAlgorithmSpec.scala deleted file mode 100644 index a38a690..0000000 --- a/src/test/scala/org/janjaali/sprayjwt/algorithms/HashingAlgorithmSpec.scala +++ /dev/null @@ -1,32 +0,0 @@ -package org.janjaali.sprayjwt.algorithms - -import org.scalatest.FunSpec - -class HashingAlgorithmSpec extends FunSpec { - - describe("HashingAlgorithm") { - describe("maps Strings to HashingAlgorithm") { - it("maps HS256") { - HashingAlgorithm("HS256") match { - case Some(alg) => assert(alg == HS256) - case _ => fail - } - } - - it("maps HS384") { - HashingAlgorithm("HS384") match { - case Some(alg) => assert(alg == HS384) - case _ => fail - } - } - - it("maps HS512") { - HashingAlgorithm("HS512") match { - case Some(alg) => assert(alg == HS512) - case _ => fail - } - } - } - } - -} diff --git a/src/test/scala/org/janjaali/sprayjwt/encoder/Base64EncoderSpec.scala b/src/test/scala/org/janjaali/sprayjwt/encoder/Base64EncoderSpec.scala deleted file mode 100644 index b59d30b..0000000 --- a/src/test/scala/org/janjaali/sprayjwt/encoder/Base64EncoderSpec.scala +++ /dev/null @@ -1,20 +0,0 @@ -package org.janjaali.sprayjwt.encoder - -import org.scalatest.FunSpec - -class Base64EncoderSpec extends FunSpec { - - describe("Base64Encoder") { - it("encodes text as Base64 encoded text") { - val encodedString = Base64Encoder.encode("""{"alg":"HS256","typ":"JWT"}""") - assert(encodedString == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") - } - - it("encodes byteArray as Base64 encoded text") { - val byteArray = """{"alg":"HS256","typ":"JWT"}""".getBytes("UTF-8") - val encodedString = Base64Encoder.encode(byteArray) - assert(encodedString == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") - } - } - -} diff --git a/src/test/scala/org/janjaali/sprayjwt/headers/JwtHeaderJsonProtocolSpec.scala b/src/test/scala/org/janjaali/sprayjwt/headers/JwtHeaderJsonProtocolSpec.scala deleted file mode 100644 index 6c25539..0000000 --- a/src/test/scala/org/janjaali/sprayjwt/headers/JwtHeaderJsonProtocolSpec.scala +++ /dev/null @@ -1,19 +0,0 @@ -package org.janjaali.sprayjwt.headers - -import org.janjaali.sprayjwt.algorithms.HS256 -import org.scalatest.FunSpec -import spray.json._ - -class JwtHeaderJsonProtocolSpec extends FunSpec { - - describe("JwtHeaderJsonProtocol trait") { - it("converts JWT-Header to JsValue") { - val jwtHeaderJson = JwtHeader(HS256).toJson - assert(jwtHeaderJson == JsObject( - "typ" -> JsString("JWT"), - "alg" -> JsString("HS256") - )) - } - } - -}