Skip to content

Commit

Permalink
feat: add JWT and claim validation directives (#4377)
Browse files Browse the repository at this point in the history
---------
Co-authored-by: Johan Andrén <[email protected]>
  • Loading branch information
efgpinto authored Apr 17, 2024
1 parent 3534757 commit bdcff24
Show file tree
Hide file tree
Showing 27 changed files with 1,466 additions and 4 deletions.
3 changes: 2 additions & 1 deletion akka-http-core/src/main/scala/akka/http/scaladsl/Http.scala
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ class HttpExt @InternalStableApi /* constructor signature is hardcoded in Teleme
"akka-http-marshallers-java",
"akka-http-spray-json",
"akka-http-xml",
"akka-http-jackson"
"akka-http-jackson",
"akka-http-jwt"
)

ManifestInfo(system).checkSameVersion("Akka HTTP", allModules, logWarning = true)
Expand Down
54 changes: 54 additions & 0 deletions akka-http-jwt/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#######################################
# akka-http-jwt Reference Config File #
#######################################

# This is the reference config file that contains all the default settings.
# Make your edits/overrides in your application.conf.

akka.http {
#jwt
jwt {
# Enables or disables the JWT signature validation.
# This is useful for development and testing purposes
# where you can still assert presence of claims without using a real signature.
dev = off

# The realm to use in the WWW-Authenticate header when a token is missing or invalid.
realm = "akka-http-jwt"

# Allows configuration for the JWT secrets used to verify tokens.
# The list of supported algorithms is as follows:
# - symmetric: HMD5, HS224, HS256, HS384 and HS512
# - asymmetric: RS256, RS384, RS512, ES256, ES384, ES512 and Ed25519
# Symmetric algorithms require either a secret in 'secret' or a filesystem path with a secret via 'secret-path', the former is ignored and the later takes precedence.
# Asymmetric algorithms require a filesystem path for a public key via 'public-key'.
#
# An example config would be:
# secrets: [
# {
# # The key-id is mandatory and should be unique for each secret.
# key-id: my-key-symmetric
# # The issuer is optional and can be used to validate the 'iss' claim.
# issuer: my-issuer
# algorithm: HS256
# # The secret can be set via an environment variable or loaded from a file.
# # To load the secret from an environment variables use:
# secret: ${MY_PRECIOUS_SECRET}
# # To load the secret from a file use (and remove the above secret setting):
# # secret-path: /path/to/secret.key
# },
# {
# key-id: my-key-asymmetric
# issuer: my-issuer
# algorithm: RS256
# # The public key used for JWT validation should be provided with the following setting:
# public-key: /path/to/public.key
# }
# ]
#
# NOTE: If configuring multiple secrets for the same algorithm, the first one found will be used
# in cases where the Key Id ("kid") is not specified in the JWT token header.
secrets: []
}
#jwt
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright (C) 2024 Lightbend Inc. <https://www.lightbend.com>
*/

package akka.http.jwt.internal

import akka.annotation.InternalApi
import akka.http.jwt.scaladsl.server.directives.JwtClaims
import akka.http.jwt.javadsl.server.directives.{ JwtClaims => JavaJwtClaims }
import spray.json.{ JsArray, JsBoolean, JsNumber, JsObject, JsString, JsValue }

import java.util.Optional
import scala.compat.java8.OptionConverters.RichOptionForJava8
import scala.jdk.CollectionConverters.SeqHasAsJava

/**
* INTERNAL API
*
* JwtClaims provides utilities to easily assert and extract claims from the JWT token
*/
@InternalApi
private[jwt] final case class JwtClaimsImpl(claims: JsObject) extends JwtClaims with JavaJwtClaims {

override def hasClaim(name: String): Boolean = claims.fields.contains(name)

override def intClaim(name: String): Option[Int] = claims.fields.get(name).collect { case JsNumber(value) => value.toInt }

override def longClaim(name: String): Option[Long] = claims.fields.get(name).collect { case JsNumber(value) => value.toLong }

override def doubleClaim(name: String): Option[Double] = claims.fields.get(name).collect { case JsNumber(value) => value.toDouble }

override def stringClaim(name: String): Option[String] = claims.fields.get(name).collect { case JsString(value) => value }

override def stringClaims(name: String): List[String] = claims.fields.get(name)
.collect {
case JsArray(elems) =>
elems.collect { case JsString(value) => value }
}
.map(_.toList)
.getOrElse(List.empty[String])

override def booleanClaim(name: String): Option[Boolean] = claims.fields.get(name).collect { case JsBoolean(value) => value }

override def rawClaim(name: String): Option[JsValue] = claims.fields.get(name)

// JAVA API
override def getIntClaim(name: String): Optional[Int] = intClaim(name).asJava

override def getLongClaim(name: String): Optional[Long] = longClaim(name).asJava

override def getDoubleClaim(name: String): Optional[Double] = doubleClaim(name).asJava

override def getStringClaim(name: String): Optional[String] = stringClaim(name).asJava

override def getStringClaims(name: String): java.util.List[String] = stringClaims(name).asJava

override def getBooleanClaim(name: String): Optional[Boolean] = booleanClaim(name).asJava

override def getRawClaim(name: String): Optional[String] = rawClaim(name).map(_.toString).asJava
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright (C) 2024 Lightbend Inc. <https://www.lightbend.com>
*/

package akka.http.jwt.internal

import JwtSupport.{ JwtAsymmetricAlgorithmSecret, PrivateKeyConfig, PublicKeyConfig }
import akka.annotation.InternalApi
import akka.pki.pem.{ DERPrivateKeyLoader, PEMDecoder, PEMLoadingException }
import com.typesafe.config.Config
import pdi.jwt.{ JwtAlgorithm, algorithms }

import java.io.File
import java.nio.file.Files
import java.security.spec.{ PKCS8EncodedKeySpec, X509EncodedKeySpec }
import java.security.{ KeyFactory, KeyPair }
import javax.crypto.spec.SecretKeySpec

/**
* INTERNAL API
*
* Loads a key from a directory.
*/
@InternalApi
private[jwt] object JwtKeyLoader {

def loadKey(keyId: String, algorithm: JwtAlgorithm, secretConfig: Config): JwtSupport.JwtAlgorithmSecret = {
algorithm match {

case symmetric: algorithms.JwtHmacAlgorithm =>
val secretKeyFile = new File(secretConfig.getString("secret-path"))
if (secretKeyFile.exists()) {
JwtSupport.JwtSymmetricAlgorithmSecret(
symmetric,
new SecretKeySpec(Files.readAllBytes(secretKeyFile.toPath), algorithm.fullName))
} else {
throwIAE(s"Expected a symmetric secret configured for JWT key with id [$keyId]")
}

case asymmetric: algorithms.JwtAsymmetricAlgorithm =>
val keyAlgo = asymmetric match {
case _: algorithms.JwtRSAAlgorithm => "RSA"
case _: algorithms.JwtECDSAAlgorithm => "EC"
case _: algorithms.JwtEdDSAAlgorithm => "EdDSA"
}

val publicKeyFile = new File(secretConfig.getString(PublicKeyConfig))
if (publicKeyFile.exists()) {
val pem = loadPem(publicKeyFile, keyId)
pem.label match {
case "PUBLIC KEY" =>
try {
val publicKey = KeyFactory.getInstance(keyAlgo).generatePublic(new X509EncodedKeySpec(pem.bytes))
JwtAsymmetricAlgorithmSecret(asymmetric, publicKey)
} catch {
case e: Exception => throwIAE(s"Error decoding JWT public key from key id [$keyId]: ${e.getMessage}", e)
}
case _ => throwIAE(s"Unsupported JWT public key format for key id [$keyId]: ${pem.label}")
}
} else {
throwIAE(s"Public key configured for JWT key with id [$keyId] could not be found or read.")
}

case other =>
throwIAE(s"Unknown JWT algorithm for key id [$keyId]: $other")
}

}

private def loadPem(file: File, keyId: String): PEMDecoder.DERData = {
try {
PEMDecoder.decode(new String(Files.readAllBytes(file.toPath)))
} catch {
case e: PEMLoadingException =>
throwIAE(s"Error PEM decoding JWT public key from key id [$keyId]: ${e.getMessage}", e)
}
}

private def throwIAE(msg: String, e: Exception = null): Nothing = throw new IllegalArgumentException(msg, e)

}
Loading

0 comments on commit bdcff24

Please sign in to comment.