-
Notifications
You must be signed in to change notification settings - Fork 595
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add JWT and claim validation directives (#4377)
--------- Co-authored-by: Johan Andrén <[email protected]>
- Loading branch information
Showing
27 changed files
with
1,466 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
60 changes: 60 additions & 0 deletions
60
akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtClaimsImpl.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
81 changes: 81 additions & 0 deletions
81
akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtKeyLoader.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
|
||
} |
Oops, something went wrong.