diff --git a/akka-http-core/src/main/scala/akka/http/scaladsl/Http.scala b/akka-http-core/src/main/scala/akka/http/scaladsl/Http.scala index 8f234c63a2..57ebe50da7 100644 --- a/akka-http-core/src/main/scala/akka/http/scaladsl/Http.scala +++ b/akka-http-core/src/main/scala/akka/http/scaladsl/Http.scala @@ -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) diff --git a/akka-http-jwt/src/main/resources/reference.conf b/akka-http-jwt/src/main/resources/reference.conf new file mode 100644 index 0000000000..44bb2868ae --- /dev/null +++ b/akka-http-jwt/src/main/resources/reference.conf @@ -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 +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtClaimsImpl.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtClaimsImpl.scala new file mode 100644 index 0000000000..4ac6a7737c --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtClaimsImpl.scala @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +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 +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtKeyLoader.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtKeyLoader.scala new file mode 100644 index 0000000000..00ffac7210 --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtKeyLoader.scala @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +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) + +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtSettingsImpl.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtSettingsImpl.scala new file mode 100644 index 0000000000..2f4a8d4087 --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtSettingsImpl.scala @@ -0,0 +1,246 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +package akka.http.jwt.internal + +import akka.annotation.InternalApi +import akka.http.impl.util.SettingsCompanionImpl +import com.typesafe.config.Config +import pdi.jwt.{ JwtAlgorithm, JwtOptions, algorithms } +import spray.json.{ JsObject, JsString } + +import java.security.PublicKey +import java.util.Base64 +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec +import scala.jdk.CollectionConverters.CollectionHasAsScala +import scala.util.{ Failure, Success, Try } + +/** INTERNAL API */ +@InternalApi +private[jwt] final case class JwtSettingsImpl( + jwtSupport: JwtSupport, + realm: String, + devMode: Boolean +) extends akka.http.jwt.scaladsl.JwtSettings { + + override def productPrefix = "JwtSettings" +} + +/** + * INTERNAL API + */ +@InternalApi +private[jwt] object JwtSettingsImpl extends SettingsCompanionImpl[JwtSettingsImpl]("akka.http.jwt") { + + override def fromSubConfig(root: Config, inner: Config): JwtSettingsImpl = { + val c = inner.withFallback(root.getConfig(prefix)) + new JwtSettingsImpl( + JwtSupport.fromConfig(c), + c.getString("realm"), + c.getBoolean("dev")) + } +} + +/** + * INTERNAL API + */ +@InternalApi +private[jwt] trait JwtSupport { + + def canValidate: Boolean + + def validate(token: String): Either[Exception, JsObject] + +} + +/** + * INTERNAL API + */ +@InternalApi +private[jwt] object JwtSupport { + + private val NoValidationOptions = JwtOptions.DEFAULT.copy(signature = false, expiration = false, notBefore = false) + + private[jwt] val PrivateKeyConfig = "private-key" + private[jwt] val PublicKeyConfig = "public-key" + + private case class JwtValidationException(msg: String) extends RuntimeException(msg) + + def fromConfig(jwtConfig: Config): JwtSupport = { + val devSecret = if (jwtConfig.getBoolean("dev")) { + Some(JwtSecret("dev", None, JwtNoneAlgorithmSecret)) + } else None + + val secrets = jwtConfig + .getConfigList("secrets") + .asScala + .map { secretConfig => + val keyId = secretConfig.getString("key-id") + val algorithmSecret: JwtAlgorithmSecret = secretConfig.getString("algorithm") match { + case "none" => JwtNoneAlgorithmSecret + case alg => + val algorithm = JwtAlgorithm.fromString(alg) + if (secretConfig.hasPath(PublicKeyConfig)) { + JwtKeyLoader.loadKey(keyId, algorithm, secretConfig) + } else if (secretConfig.hasPath("secret")) { + algorithm match { + case symmetric: algorithms.JwtHmacAlgorithm => + val base64Secret = secretConfig.getString("secret") + val secretBytes = Base64.getDecoder.decode(base64Secret) + JwtSymmetricAlgorithmSecret(symmetric, new SecretKeySpec(secretBytes, algorithm.fullName)) + case _ => + throw new IllegalArgumentException(secretLiteralNotSupportedWithAsymmetricAlgorithm(keyId, algorithm.name)) + } + } else { + throw new IllegalArgumentException( + s"JWT secret <$keyId> was not configured correctly. Depending on the used algorithm, a secret or a public key must be configured.") + } + } + val issuer = + if (secretConfig.hasPath("issuer")) Some(secretConfig.getString("issuer")).filter(_.nonEmpty) else None + JwtSecret(keyId, issuer, algorithmSecret) + } + .toList + + new DefaultJwtSupport(secrets ++ devSecret) + } + + /** + * @param secrets Order of this list represents priority when selecting secrets for signing and validation. + */ + final class DefaultJwtSupport(secrets: List[JwtSecret]) extends JwtSupport { + private val validatingSecrets = secrets.filter(_.secret.canValidate) + private val validatingSecretsByIssuer = validatingSecrets + .groupBy(_.issuer) + .collect { + case (Some(issuer), s) => + issuer -> s + } + + override def canValidate: Boolean = validatingSecrets.nonEmpty + + override def validate(token: String): Either[Exception, JsObject] = { + // First, decode without validating so we can get information like key id and issuer + JwtSprayJson.decodeAll(token, NoValidationOptions) match { + case Success((header, claim, _)) => + // If the claim has an issuer, and there are secrets with that issuers name, then restrict to only using + // those issuers. + val issuerBasedValidators = claim.issuer match { + case Some(issuer) => + validatingSecretsByIssuer.get(issuer) match { + case Some(secrets) => secrets + case None => + // No secrets for that issuer, try secrets without issuer specified + validatingSecrets.filter(_.issuer.isEmpty) + } + case None => validatingSecrets + } + issuerBasedValidators match { + case single :: Nil => + // We have a single validating secret, just use that and hope for the best. + validateToken(token, single) + case _ => + // Filter out the secrets that can't be used for this tokens algorithm + val validForAlgorithm = header.algorithm match { + case Some(alg) => + issuerBasedValidators.filter(_.secret.canValidateAlgorithm(alg)) + // None either means there was no algorithm specified, or was "none". Assume it was "none". + case None => + issuerBasedValidators.filter(_.secret == JwtNoneAlgorithmSecret) + } + validForAlgorithm match { + case Nil => + Left(JwtValidationException("Failed to verify JWT token due to unsupported algorithm")) + case single :: Nil => + // Now we have a single secret, use that and hope for the best. + validateToken(token, single) + case _ => + // Let's match by keyid + header.keyId match { + case None => + // No keyId, just use the first one + validateToken(token, validForAlgorithm.head) + case Some(keyId) => + // Matching against all validators for this issuer since key ids should be unique. + issuerBasedValidators.find(_.keyId == keyId) match { + case Some(matching) => validateToken(token, matching) + case None => + Left(JwtValidationException("Failed to verify JWT token due to unknown key id")) + } + } + } + } + case Failure(e) => + Left(JwtValidationException(s"Failed to parse JWT token: ${e.getMessage}")) + } + } + + private def validateToken(token: String, secret: JwtSecret): Either[Exception, JsObject] = { + secret.secret.validate(token) match { + case Success(value) => Right(value) + case Failure(e) => + Left(JwtValidationException(s"JWT token validation failed: ${e.getMessage}")) + } + } + } + + sealed trait JwtAlgorithmSecret { + def canValidateAlgorithm(alg: JwtAlgorithm): Boolean + def canValidate: Boolean + def validate(token: String): Try[JsObject] + def algorithmName: String + } + + case class JwtAsymmetricAlgorithmSecret(algorithm: algorithms.JwtAsymmetricAlgorithm, publicKey: PublicKey) + extends JwtAlgorithmSecret { + override def canValidateAlgorithm(alg: JwtAlgorithm): Boolean = { + canValidate && (alg match { + case _: algorithms.JwtAsymmetricAlgorithm => + algorithm match { + case _: algorithms.JwtRSAAlgorithm => alg.isInstanceOf[algorithms.JwtRSAAlgorithm] && canValidate + case _: algorithms.JwtECDSAAlgorithm => alg.isInstanceOf[algorithms.JwtECDSAAlgorithm] && canValidate + case _: algorithms.JwtEdDSAAlgorithm => alg.isInstanceOf[algorithms.JwtEdDSAAlgorithm] && canValidate + case _ => false + } + case _ => false + }) + } + + override def canValidate: Boolean = publicKey != null + override def validate(token: String): Try[JsObject] = if (canValidate) { + JwtSprayJson.decodeJson(token, publicKey) + } else { + // This key should have already been excluded as a candidate for validation + Failure(JwtValidationException("Key does not have a public component")) + } + + override def algorithmName: String = algorithm.name + } + + case class JwtSymmetricAlgorithmSecret(algorithm: algorithms.JwtHmacAlgorithm, key: SecretKey) + extends JwtAlgorithmSecret { + override def canValidateAlgorithm(alg: JwtAlgorithm): Boolean = alg.isInstanceOf[algorithms.JwtHmacAlgorithm] + override def canValidate: Boolean = true + override def validate(token: String): Try[JsObject] = JwtSprayJson.decodeJson(token, key) + override def algorithmName: String = algorithm.name + } + + case object JwtNoneAlgorithmSecret extends JwtAlgorithmSecret { + private val noSignatureOptions = JwtOptions.DEFAULT.copy(signature = false) + override def canValidateAlgorithm(alg: JwtAlgorithm): Boolean = false + override def canValidate: Boolean = true + override def validate(token: String): Try[JsObject] = JwtSprayJson.decodeJson(token, noSignatureOptions) + override def algorithmName: String = "none" + } + + case class JwtSecret(keyId: String, issuer: Option[String], secret: JwtAlgorithmSecret) { + val header: JsObject = new JsObject(Vector("alg" -> JsString(secret.algorithmName), "kid" -> JsString(keyId)).toMap) + } + + private def secretLiteralNotSupportedWithAsymmetricAlgorithm(keyId: String, algorithm: String): String = + s"Secret literal for key id [$keyId] not supported with asymmetric algorithms: $algorithm. " + + "Secret literals are only supported with symmetric (HMAC) algorithms." + +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtSprayJson.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtSprayJson.scala new file mode 100644 index 0000000000..9006d8d909 --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/internal/JwtSprayJson.scala @@ -0,0 +1,94 @@ +/* + * Copyright 2024 Lightbend Inc. + */ + +/* + * Copied from github.com/jwt-scala/jwt-scala/ with this license: + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package akka.http.jwt.internal + +import akka.annotation.InternalApi +import pdi.jwt.exceptions.JwtNonStringException +import pdi.jwt.{ JwtAlgorithm, JwtClaim, JwtHeader, JwtJsonCommon } +import spray.json._ + +import java.time.Clock + +/** + * INTERNAL API + * + * Implementation of `JwtCore` using `JsObject` from spray-json. + * + * This class originally came from jwt-spray-json, + * but was removed in https://github.com/jwt-scala/jwt-scala/commit/bf1131ce02480103c0b953b97da001105a3ee038 + */ +@InternalApi +private[jwt] trait JwtSprayJsonParser[H, C] extends JwtJsonCommon[JsObject, H, C] { + protected def parse(value: String): JsObject = value.parseJson.asJsObject + + protected def stringify(value: JsObject): String = value.compactPrint + + protected def getAlgorithm(header: JsObject): Option[JwtAlgorithm] = + header.fields.get("alg").flatMap { + case JsString("none") => None + case JsString(algo) => Option(JwtAlgorithm.fromString(algo)) + case JsNull => None + case _ => throw new JwtNonStringException("alg") + } + +} + +/** INTERNAL API */ +@InternalApi +private[jwt] object JwtSprayJson extends JwtSprayJson(Clock.systemUTC) { + def apply(clock: Clock): JwtSprayJson = new JwtSprayJson(clock) +} + +/** INTERNAL API */ +@InternalApi +private[jwt] class JwtSprayJson(override val clock: Clock) extends JwtSprayJsonParser[JwtHeader, JwtClaim] { + + import DefaultJsonProtocol._ + override def parseHeader(header: String): JwtHeader = { + val jsObj = parse(header) + JwtHeader( + algorithm = getAlgorithm(jsObj), + typ = safeGetField[String](jsObj, "typ"), + contentType = safeGetField[String](jsObj, "cty"), + keyId = safeGetField[String](jsObj, "kid")) + } + + override def parseClaim(claim: String): JwtClaim = { + val jsObj = parse(claim) + val content = JsObject(jsObj.fields - "iss" - "sub" - "aud" - "exp" - "nbf" - "iat" - "jti") + JwtClaim( + content = stringify(content), + issuer = safeGetField[String](jsObj, "iss"), + subject = safeGetField[String](jsObj, "sub"), + audience = safeGetField[Set[String]](jsObj, "aud") + .orElse(safeGetField[String](jsObj, "aud").map(s => Set(s))), + expiration = safeGetField[Long](jsObj, "exp"), + notBefore = safeGetField[Long](jsObj, "nbf"), + issuedAt = safeGetField[Long](jsObj, "iat"), + jwtId = safeGetField[String](jsObj, "jti")) + } + + private[this] def safeRead[A: JsonReader](js: JsValue) = + safeReader[A].read(js).fold(_ => None, a => Option(a)) + + private[this] def safeGetField[A: JsonReader](js: JsObject, name: String) = + js.fields.get(name).flatMap(safeRead[A]) +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/javadsl/JwtSettings.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/javadsl/JwtSettings.scala new file mode 100644 index 0000000000..cf9fd6061a --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/javadsl/JwtSettings.scala @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +package akka.http.jwt.javadsl + +import akka.annotation.{ ApiMayChange, DoNotInherit, InternalApi } +import akka.http.jwt.internal.{ JwtSettingsImpl, JwtSupport } + +/** + * Public API but not intended for subclassing + */ +@ApiMayChange @DoNotInherit +abstract class JwtSettings private[akka] { self: JwtSettingsImpl => + + def realm: String + + def devMode: Boolean +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/javadsl/server/directives/JwtClaims.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/javadsl/server/directives/JwtClaims.scala new file mode 100644 index 0000000000..18a61b104f --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/javadsl/server/directives/JwtClaims.scala @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +package akka.http.jwt.javadsl.server.directives + +import akka.annotation.DoNotInherit + +import java.util.Optional + +/** + * JwtClaims provides a utility to access claims extracted from a JWT token. + * Not for user extension + */ +@DoNotInherit +trait JwtClaims { + + /** + * Checks if a claim with the given name exists in the list of claims. + * + * @param name the name of the claim. + * @return true if the claim exists, false otherwise. + */ + def hasClaim(name: String): Boolean + + /** + * Extracts an integer claim from the list of claims. + * + * @param name the name of the claim. + * @return an Optional containing the integer value of the claim if it exists and is an integer, Optional.empty otherwise. + */ + def getIntClaim(name: String): Optional[Int] + + /** + * Extracts a long claim from the list of claims. + * + * @param name the name of the claim. + * @return an Optional containing the long value of the claim if it exists and is a long, Optional.empty otherwise. + */ + def getLongClaim(name: String): Optional[Long] + + /** + * Extracts a double claim from the list of claims. + * + * @param name the name of the claim. + * @return an Optional containing the double value of the claim if it exists and is a double, Optional.empty otherwise. + */ + def getDoubleClaim(name: String): Optional[Double] + + /** + * Extracts a string claim from the list of claims. + * + * @param name the name of the claim. + * @return an Optional containing the string value of the claim if it exists and is a string, Optional.empty otherwise. + */ + def getStringClaim(name: String): Optional[String] + + /** + * Extracts a list of string claims from the list of claims. + * + * @param name the name of the claim. + * @return a List containing the string values of the claim if it exists and is a list of strings, empty list otherwise. + */ + def getStringClaims(name: String): java.util.List[String] + + /** + * Extracts a boolean claim from the list of claims. + * + * @param name the name of the claim. + * @return an Optional containing the boolean value of the claim if it exists and is a boolean, Optional.empty otherwise. + */ + def getBooleanClaim(name: String): Optional[Boolean] + + /** + * Extracts a raw claim from the list of claims. + * This can be useful if you need to extract a claim that is not a primitive type but a complex one. + * + * @param name the name of the claim. + * @return an Optional containing the raw JSON String value of the claim if it exists, Optional.empty otherwise. + */ + def getRawClaim(name: String): Optional[String] +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/javadsl/server/directives/JwtDirectives.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/javadsl/server/directives/JwtDirectives.scala new file mode 100644 index 0000000000..18b443be41 --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/javadsl/server/directives/JwtDirectives.scala @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +package akka.http.jwt.javadsl.server.directives + +import akka.http.javadsl.server.Route +import akka.http.javadsl.server.directives.RouteAdapter +import akka.http.jwt.internal.JwtClaimsImpl +import akka.http.jwt.scaladsl.server.directives.{ JwtDirectives => JD } +import akka.http.jwt.javadsl.JwtSettings + +import java.util.function.{ Function => JFunction } + +/** + * JwtDirectives provides utilities to easily assert and extract claims from a JSON Web Token (JWT). + * + * For more information about JWTs, see [[https://jwt.io/]] or consult RFC 7519: [[https://datatracker.ietf.org/doc/html/rfc7519]] + */ +abstract class JwtDirectives { + + /** + * Wraps its inner route with support for the JWT mechanism, enabling JWT token validation. + * JWT token validation is done automatically extracting the token from the Authorization header. + * If the token is valid, the inner route is executed and provided the set of claims as [[JwtClaims]], + * otherwise a 401 Unauthorized response is returned. + */ + def jwt(inner: JFunction[JwtClaims, Route]): Route = RouteAdapter { + JD.jwt() { claims => + inner.apply(claims.asInstanceOf[JwtClaimsImpl]).delegate + } + } + + /** + * Wraps its inner route with support for the JWT mechanism, enabling JWT token validation using the given jwt settings. + * JWT token validation is done automatically extracting the token from the Authorization header. + * If the token is valid, the inner route is executed and provided the set of claims as [[JwtClaims]], + * otherwise a 401 Unauthorized response is returned. + */ + def jwt(settings: JwtSettings, inner: JFunction[JwtClaims, Route]): Route = RouteAdapter { + JD.jwt(settings.asInstanceOf[akka.http.jwt.scaladsl.JwtSettings]) { claims => + inner.apply(claims.asInstanceOf[JwtClaimsImpl]).delegate + } + } +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/JwtSettings.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/JwtSettings.scala new file mode 100644 index 0000000000..42a005ca56 --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/JwtSettings.scala @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +package akka.http.jwt.scaladsl + +import akka.actor.ClassicActorSystemProvider +import akka.annotation.{ ApiMayChange, DoNotInherit, InternalApi } +import akka.http.jwt.internal.{ JwtSettingsImpl, JwtSupport } +import com.typesafe.config.Config + +/** + * Public API but not intended for subclassing + */ +@ApiMayChange @DoNotInherit +trait JwtSettings extends akka.http.jwt.javadsl.JwtSettings { self: JwtSettingsImpl => + /** INTERNAL API */ + @InternalApi private[akka] override def jwtSupport: JwtSupport + + override def realm: String + + override def devMode: Boolean +} + +object JwtSettings { + def apply(system: ClassicActorSystemProvider): JwtSettings = + JwtSettingsImpl(system.classicSystem) + def apply(config: Config): JwtSettings = + JwtSettingsImpl(config) +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/server/directives/JwtClaims.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/server/directives/JwtClaims.scala new file mode 100644 index 0000000000..283418689f --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/server/directives/JwtClaims.scala @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +package akka.http.jwt.scaladsl.server.directives + +import akka.annotation.DoNotInherit +import spray.json.JsValue + +/** + * JwtClaims provides a utility to access claims extracted from a JWT token. + * Not for user extension + */ +@DoNotInherit +trait JwtClaims { + + /** + * Checks if a claim with the given name exists in the list of claims. + * + * @param name the name of the claim. + * @return true if the claim exists, false otherwise. + */ + def hasClaim(name: String): Boolean + + /** + * Extracts an integer claim from the list of claims. + * + * @param name the name of the claim. + * @return an Option containing the integer value of the claim if it exists and is an integer, None otherwise. + */ + def intClaim(name: String): Option[Int] + + /** + * Extracts a long claim from the list of claims. + * + * @param name the name of the claim. + * @return an Option containing the long value of the claim if it exists and is a long, None otherwise. + */ + def longClaim(name: String): Option[Long] + + /** + * Extracts a double claim from the list of claims. + * + * @param name the name of the claim. + * @return an Option containing the double value of the claim if it exists and is a double, None otherwise. + */ + def doubleClaim(name: String): Option[Double] + + /** + * Extracts a string claim from the list of claims. + * + * @param name the name of the claim. + * @return an Option containing the string value of the claim if it exists and is a string, None otherwise. + */ + def stringClaim(name: String): Option[String] + + /** + * Extracts a list of string claims from the list of claims. + * + * @param name the name of the claim. + * @return a List containing the string values of the claim if it exists and is a list of strings, empty list otherwise. + */ + def stringClaims(name: String): List[String] + + /** + * Extracts a boolean claim from the list of claims. + * + * @param name the name of the claim. + * @return an Option containing the boolean value of the claim if it exists and is a boolean, None otherwise. + */ + def booleanClaim(name: String): Option[Boolean] + + /** + * Extracts a raw claim from the list of claims. + * This can be useful if you need to extract a claim that is not a primitive type but a complex one. + * + * @param name the name of the claim. + * @return an Option containing the raw JsValue of the claim if it exists, None otherwise. + */ + def rawClaim(name: String): Option[JsValue] + +} diff --git a/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/server/directives/JwtDirectives.scala b/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/server/directives/JwtDirectives.scala new file mode 100644 index 0000000000..64bfb766dc --- /dev/null +++ b/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/server/directives/JwtDirectives.scala @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +package akka.http.jwt.scaladsl.server.directives + +import akka.event.LoggingAdapter +import akka.http.jwt.internal.{ JwtClaimsImpl, JwtSupport } +import akka.http.jwt.scaladsl +import akka.http.scaladsl.server.Directive1 +import akka.http.scaladsl.server.directives.BasicDirectives.{ extractActorSystem, extractLog, provide } +import akka.http.scaladsl.server.directives.Credentials +import akka.http.scaladsl.server.directives.SecurityDirectives.{ Authenticator, authenticateOAuth2 } +import spray.json.JsObject + +import scala.concurrent.duration.DurationInt + +/** + * JwtDirectives provides utilities to assert and extract claims from a JSON Web Token (JWT). + * + * For more information about JWTs, see [[https://jwt.io/]] or consult RFC 7519: [[https://datatracker.ietf.org/doc/html/rfc7519]] + */ +trait JwtDirectives { + + @volatile private var lastWarningTs: Long = 0L + + /** + * Wraps its inner route with support for the JWT mechanism, enabling JWT token validation. + * JWT token validation is done automatically extracting the token from the Authorization header. + * If the token is valid, the inner route is executed and provided the set of claims as [[JwtClaims]], + * otherwise a 401 Unauthorized response is returned. + */ + def jwt(): Directive1[JwtClaims] = { + extractActorSystem.flatMap { system => + jwt(scaladsl.JwtSettings(system)) + } + } + + /** + * Wraps its inner route with support for the JWT mechanism, enabling JWT token validation using the given jwt settings. + * JWT token validation is done automatically extracting the token from the Authorization header. + * If the token is valid, the inner route is executed and provided the set of claims as [[JwtClaims]], + * otherwise a 401 Unauthorized response is returned. + */ + def jwt(settings: scaladsl.JwtSettings): Directive1[JwtClaims] = { + extractLog.flatMap { log => + // log once every minute if dev mode is enabled + if (settings.devMode && (System.currentTimeMillis() - lastWarningTs) > 1.minute.toMillis) { + log.warning("Dev mode is enabled thus JWT signatures are not verified. This must not be used in production. To disable, set config: 'akka.http.jwt.dev = off'") + lastWarningTs = System.currentTimeMillis() + } + + authenticateOAuth2(settings.realm, bearerTokenAuthenticator(settings.jwtSupport, log)).flatMap { claims => + provide(JwtClaimsImpl(claims)) + } + } + } + + private def bearerTokenAuthenticator(jwtSupport: JwtSupport, log: LoggingAdapter): Authenticator[JsObject] = { + case p @ Credentials.Provided(token) => + jwtSupport.validate(token) match { + case Right(claims) => Some(claims) + case Left(ex) => + log.debug("The token was rejected: {}", ex.getMessage) + None // FIXME: should we propagate anything else further? + } + } + +} + +object JwtDirectives extends JwtDirectives diff --git a/akka-http-jwt/src/test/resources/README.md b/akka-http-jwt/src/test/resources/README.md new file mode 100644 index 0000000000..a55aa3fe7e --- /dev/null +++ b/akka-http-jwt/src/test/resources/README.md @@ -0,0 +1,47 @@ +# Instructions on how to generate public key and JWT used in tests + + +## Generate private key + +```bash +openssl genrsa -out private.key 2048 +``` + +## Generate public key +```bash +openssl rsa -in private.key -pubout -out my-public.key +``` + +## Generate JWT + +Write below to header.txt file: +```bash +echo '{"alg": "RS256","typ": "JWT"}' > header.txt +``` + +Write below to payload.txt file: +```bash +echo '{"sub": "1234567890","name": "John Doe","admin": true}' > payload.txt +``` + +Create JWT by running below commands: +```bash +cat header.txt | tr -d '\n' | tr -d '\r' | base64 | tr +/ -_ | tr -d '=' > header.b64 +cat payload.txt | tr -d '\n' | tr -d '\r' | base64 | tr +/ -_ |tr -d '=' > payload.b64 +printf "%s" "$( unsigned.b64 +rm header.b64 +rm payload.b64 +openssl dgst -sha256 -sign private.key -out sig.txt unsigned.b64 +cat sig.txt | base64 | tr +/ -_ | tr -d '=' > sig.b64 +printf "%s" "$( my-jwt-token.txt +rm unsigned.b64 +rm sig.b64 +rm sig.txt +``` + +Remove unwanted files: +```bash +rm private.key +rm header.txt +rm payload.txt +``` diff --git a/akka-http-jwt/src/test/resources/my-jwt-token.txt b/akka-http-jwt/src/test/resources/my-jwt-token.txt new file mode 100644 index 0000000000..9d98e6d240 --- /dev/null +++ b/akka-http-jwt/src/test/resources/my-jwt-token.txt @@ -0,0 +1 @@ +eyJhbGciOiAiUlMyNTYiLCJ0eXAiOiAiSldUIn0.eyJzdWIiOiAiMTIzNDU2Nzg5MCIsIm5hbWUiOiAiSm9obiBEb2UiLCJhZG1pbiI6IHRydWV9.b5FtCouh3KZsqEPB5nzC3D5izf30-6AlFlDsV9Se0MkHbS4f3azSyxDKhbUuUmTPjVUK0QVz4iYdR6c1hJbPscspKZLESwiDqY2nI2KjAfy_mJvRei3jomJ-79KqZzdDlyNQjcoMfkqGMIm1OErSH1KL4AwSf2x9a20TxQ-ipqlA0OjhWwQee52VLSQlKrvXim5VJNPSFSrQ1VFJltNc7_MChIuV8uS-4ZDUqqAgNA4Ocrb6ZGijM-wc1YoqF5wJBL0QApV6xCaARXRRHBRQQbn3cn5xlygmqG2s_EkcgwPahIqhauwm2TM7dQcbxtORjuGZuaROFUDumFvSIgUdJQ \ No newline at end of file diff --git a/akka-http-jwt/src/test/resources/my-public.key b/akka-http-jwt/src/test/resources/my-public.key new file mode 100644 index 0000000000..d94d6eeda0 --- /dev/null +++ b/akka-http-jwt/src/test/resources/my-public.key @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo2Mnhlxupv2yLuiLxVrV +dKTWaACBzAwIFzj4VmhAH29G+BC9KIGpa5fjPNu5FxTjc97XGN3Al+Zsog4l0p7D +JKayTLF0rExuiib0PuXrIl127eZimWGPsXqJ/wUgsA23LItGzM7RsYXLDCxPj3D/ +PRRD3CT8EEw+HpnIqSZXeBQOPJicryQ4iSogM0DwVEXQ/Li7bmXZ0GcJEbYjIz6k +6MWJ3azKkEEheJ+qqCBV18boc9OcIVY7M+UvGdpkCHNjK7qKDAxfOSyxN/lCU+qZ +HT50J14slAl662VedUYiXxh/6JY/rijNW223yFhNf/DgMZeMc8wiTq2enGHHiH6L +bwIDAQAB +-----END PUBLIC KEY----- diff --git a/akka-http-jwt/src/test/scala/akka/http/jwt/scaladsl/server/directives/JwtDirectivesSpec.scala b/akka-http-jwt/src/test/scala/akka/http/jwt/scaladsl/server/directives/JwtDirectivesSpec.scala new file mode 100644 index 0000000000..b2f3aee205 --- /dev/null +++ b/akka-http-jwt/src/test/scala/akka/http/jwt/scaladsl/server/directives/JwtDirectivesSpec.scala @@ -0,0 +1,358 @@ +/* + * Copyright (C) 2024 Lightbend Inc. + */ + +package akka.http.jwt.scaladsl.server.directives + +import akka.http.jwt.internal.{ JwtClaimsImpl, JwtSprayJson } +import akka.http.jwt.scaladsl.JwtSettings +import akka.http.scaladsl.model.HttpResponse +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.server.AuthenticationFailedRejection.CredentialsRejected +import akka.http.scaladsl.server.{ AuthenticationFailedRejection, AuthorizationFailedRejection, Directives, Route } +import akka.http.scaladsl.testkit.ScalatestRouteTest +import com.typesafe.config.ConfigFactory +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import spray.json.{ JsArray, JsBoolean, JsNumber, JsObject, JsString, JsValue } + +import java.io.File +import java.util.Base64 + +class JwtDirectivesSpec extends AnyWordSpec with ScalatestRouteTest with JwtDirectives with Directives with Matchers { + + def secret = "akka is great" + + override def testConfigSource = + s""" + akka.loglevel = DEBUG + akka.http.jwt { + dev = off + realm = my-realm + secrets: [ + { + key-id: my-key + issuer: my-issuer + algorithm: HS256 + secret: "${Base64.getEncoder.encodeToString(secret.getBytes)}" + }, + { + key-id: other-key + issuer: my-secondary-issuer + algorithm: HS256 + secret: "${Base64.getEncoder.encodeToString("akka is better than great".getBytes)}" + }, + { + key-id: my-key-no-issuer + algorithm: HS256 + secret: "${Base64.getEncoder.encodeToString("akka is top".getBytes)}" + } + ] + } + """ + + val basicClaims = Map[String, JsValue]( + "sub" -> JsString("1234567890"), + "name" -> JsString("John Doe"), + "iat" -> JsNumber(1516239022)) + + def jwtHeader(claims: Map[String, JsValue] = basicClaims, secret: String = secret): Authorization = { + val token = JwtSprayJson.encode(JsObject("alg" -> JsString("HS256")), JsObject(claims), secret) + Authorization(OAuth2BearerToken(token)) + } + + def configTemplate(secret: String) = s""" + akka.loglevel = DEBUG + akka.http.jwt { + dev = off + realm = my-realm + secrets: [ + $secret + ] + } + """ + + val credentialsRejected = AuthenticationFailedRejection(CredentialsRejected, HttpChallenges.oAuth2("my-realm")) + + "The jwt() directive" should { + + def route(): Route = + jwt() { claims => + complete(claims.asInstanceOf[JwtClaimsImpl].claims.toString) + } + + "extract the claims from a valid bearer token in the Authorization header" in { + Get() ~> addHeader(jwtHeader(basicClaims)) ~> route() ~> check { + responseAs[String] shouldBe """{"iat":1516239022,"name":"John Doe","sub":"1234567890"}""" + } + } + + "extract the claims from a valid bearer token with an issuer specified" in { + Get() ~> addHeader(jwtHeader(basicClaims + ("iss" -> JsString("my-issuer")))) ~> route() ~> check { + responseAs[String] shouldBe """{"iat":1516239022,"iss":"my-issuer","name":"John Doe","sub":"1234567890"}""" + } + + Get() ~> addHeader(jwtHeader(basicClaims + ("iss" -> JsString("my-secondary-issuer")), secret = "akka is better than great")) ~> route() ~> + check { + responseAs[String] shouldBe """{"iat":1516239022,"iss":"my-secondary-issuer","name":"John Doe","sub":"1234567890"}""" + } + } + + "process the request if there is a matching secret configured with no issuer specified" in { + val difIssuer = basicClaims + ("iss" -> JsString("other-issuer")) + Get() ~> addHeader(jwtHeader(difIssuer, secret = "akka is top")) ~> route() ~> check { + responseAs[String] shouldBe """{"iat":1516239022,"iss":"other-issuer","name":"John Doe","sub":"1234567890"}""" + } + } + + "reject the request if the bearer token is expired" in { + val expired = basicClaims + ("exp" -> JsNumber(1516239022 - 1)) + Get() ~> addHeader(jwtHeader(expired)) ~> route() ~> check { + rejection shouldEqual credentialsRejected + } + } + + "reject the request if the bearer token is used before being valid" in { + // notBefore is set to 60 seconds in the future + val notBefore = basicClaims + ("nbf" -> JsNumber(System.currentTimeMillis() / 1000 + 60)) + Get() ~> addHeader(jwtHeader(notBefore)) ~> route() ~> check { + rejection shouldEqual credentialsRejected + } + } + + "reject the request if the bearer token uses a wrong secret" in { + val token = JwtSprayJson.encode(JsObject("alg" -> JsString("HS256")), JsObject(basicClaims), "wrong-secret") + Get() ~> addHeader(Authorization(OAuth2BearerToken(token))) ~> route() ~> check { + rejection shouldEqual credentialsRejected + } + } + + "reject the request if the bearer token has a different issuer than the secret configured" in { + val difIssuer = basicClaims + ("iss" -> JsString("other-issuer")) + Get() ~> addHeader(jwtHeader(difIssuer)) ~> route() ~> check { + rejection shouldEqual credentialsRejected + } + } + + "reject the request if the bearer token has a different key-id that the secret configured" in { + val token = JwtSprayJson.encode(JsObject("alg" -> JsString("HS256"), "kid" -> JsString("other-key")), JsObject(basicClaims), secret) + Get() ~> addHeader(Authorization(OAuth2BearerToken(token))) ~> route() ~> check { + rejection shouldEqual credentialsRejected + } + } + + "reject the request if the bearer token is expired even when dev mod is on" in { + + val devModeSettings = "akka.http.jwt.dev = on" + val config = ConfigFactory.parseString(devModeSettings).withFallback(ConfigFactory.load()) + val expired = basicClaims + ("exp" -> JsNumber(1516239022 - 1)) + + val route = jwt(settings = JwtSettings.apply(config)) { _ => complete("ok") } + + Get() ~> addHeader(jwtHeader(expired)) ~> route ~> check { + rejection shouldEqual AuthenticationFailedRejection(CredentialsRejected, HttpChallenges.oAuth2("akka-http-jwt")) + } + } + } + + "The extracted JwtClaims from jwt() directive" should { + + "allow for extracting claims with a specific type" in { + val extraClaims = basicClaims + ("int" -> JsNumber(42)) + ("double" -> JsNumber(42.42)) + ("long" -> JsNumber(11111111111L)) + ("bool" -> JsBoolean(true)) + val routeWithTypedClaims = + jwt() { claims => + { + val result = for { + sub <- claims.stringClaim("sub") + int <- claims.intClaim("int") + long <- claims.longClaim("long") + double <- claims.doubleClaim("double") + bool <- claims.booleanClaim("bool") + } yield s"$sub:$int:$long:$double:$bool" + + complete(result) + } + } + + Get() ~> addHeader(jwtHeader(extraClaims)) ~> routeWithTypedClaims ~> check { + responseAs[String] shouldBe "1234567890:42:11111111111:42.42:true" + } + } + + "supply typed default values" in { + + Get() ~> addHeader(jwtHeader(basicClaims)) ~> { + jwt() { claims => + val amount = claims.intClaim("amount").getOrElse(45) + complete(amount.toString) + } + } ~> check { + responseAs[String] shouldEqual "45" + } + } + + "create typed optional parameters that extract Some(value) when present" in { + Get() ~> addHeader(jwtHeader(basicClaims + ("amount" -> JsNumber(12)))) ~> { + jwt() { claims => + val amount = claims.intClaim("amount") + complete(amount.toString) + } + } ~> check { + responseAs[String] shouldEqual "Some(12)" + } + + Get() ~> addHeader(jwtHeader(basicClaims + ("id" -> JsString("hello")))) ~> { + jwt() { claims => + val id = claims.stringClaim("id") + complete(id.toString) + } + } ~> check { + responseAs[String] shouldEqual "Some(hello)" + } + } + + "create typed optional parameters that extract None when not present" in { + Get() ~> addHeader(jwtHeader(basicClaims)) ~> { + jwt() { claims => + val amount = claims.intClaim("amount") + complete(amount.toString) + } + } ~> check { + responseAs[String] shouldEqual "None" + } + } + + "allow extraction of a raw claim" in { + val complexClaim = JsObject("id" -> JsString("abc"), "amount" -> JsNumber(12)) + Get() ~> addHeader(jwtHeader(basicClaims + ("extra" -> complexClaim))) ~> { + jwt() { + _.rawClaim("extra") match { + case Some(f: JsValue) => complete(f.asJsObject.fields("id").toString) + case _ => reject(AuthorizationFailedRejection) + } + } + } ~> check { + responseAs[String] shouldEqual "\"abc\"" // rawClaim returns the raw JSON value + } + } + + "allow extraction of a list of string claim values" in { + Get() ~> addHeader(jwtHeader(basicClaims + ("roles" -> JsArray(JsString("read"), JsString("write"))))) ~> { + jwt() { + _.stringClaims("roles") match { + case elems if elems.contains("read") => complete("ok") + case _ => reject(AuthorizationFailedRejection) + } + } + } ~> check { + responseAs[String] shouldEqual "ok" + } + } + + "allow for checking the value of the required claim" in { + Get() ~> addHeader(jwtHeader(basicClaims)) ~> { + jwt() { + _.stringClaim("role") match { + case Some("admin") => complete(HttpResponse()) + case _ => reject(AuthorizationFailedRejection) + } + } + } ~> check { + rejection shouldEqual AuthorizationFailedRejection + } + } + + "validate JWTs using asymmetric keys" in { + val asymmetricSecret = configTemplate( + s""" + { + key-id: asymmetric-key + issuer: my-issuer + algorithm: RS256 + public-key: "${getClass.getClassLoader.getResource("my-public.key").getPath}" + } + """) + + val config = ConfigFactory.parseString(asymmetricSecret).withFallback(ConfigFactory.load()) + val route = + jwt(settings = JwtSettings.apply(config)) { claims => + complete(s"${claims.stringClaim("sub").get}:${claims.stringClaim("name").get}") + } + + val jwtToken = Authorization(OAuth2BearerToken( + read(getClass.getClassLoader.getResource("my-jwt-token.txt").getPath) + )) + Get() ~> addHeader(jwtToken) ~> route ~> check { + responseAs[String] shouldBe "1234567890:John Doe" + } + } + + "reject when asymmetric secret is not properly configured" in { + { + val asymmetricUsingSecret = configTemplate( + s""" + { + key-id: asymmetric-key + issuer: my-issuer + algorithm: RS256 + secret: "something" + } + """) + + val wrongConfig = ConfigFactory.parseString(asymmetricUsingSecret).withFallback(ConfigFactory.load()) + + intercept[IllegalArgumentException] { + Get() ~> jwt(settings = JwtSettings.apply(wrongConfig)) { _ => complete("ok") } + }.getMessage should include("Secret literal for key id [asymmetric-key] not supported with asymmetric algorithms") + } + + { + val asymmetricWithoutPublicKey = configTemplate( + s""" + { + key-id: asymmetric-key + issuer: my-issuer + algorithm: RS256 + } + """) + + val wrongConfig = ConfigFactory.parseString(asymmetricWithoutPublicKey).withFallback(ConfigFactory.load()) + intercept[IllegalArgumentException] { + Get() ~> jwt(settings = JwtSettings.apply(wrongConfig)) { _ => complete("ok") } + }.getMessage should include("Depending on the used algorithm, a secret or a public key must be configured.") + } + } + + "ignore signature if using dev mode" in { + val devModeSettings = "akka.http.jwt.dev = on" + val config = ConfigFactory.parseString(devModeSettings).withFallback(ConfigFactory.load()) + + val route = + jwt(settings = JwtSettings.apply(config)) { + _.stringClaim("sub") match { + case Some(sub) => complete(sub) + case None => reject(AuthorizationFailedRejection) + } + } + + // removing the signature part of the JWT token, this makes it invalid unless dev mode is on + val jwtTokenNoSignature = + read(getClass.getClassLoader.getResource("my-jwt-token.txt").getPath).split('.').take(2).mkString(".") + + val header = Authorization(OAuth2BearerToken(jwtTokenNoSignature)) + Get() ~> addHeader(header) ~> route ~> check { + responseAs[String] shouldBe "1234567890" + } + } + + } + + private def read(filePath: String): String = { + val source = scala.io.Source.fromFile(new File(filePath), "UTF-8") + try { + source.mkString + } finally { + source.close() + } + } +} diff --git a/build.sbt b/build.sbt index aeb65a6144..7314464393 100644 --- a/build.sbt +++ b/build.sbt @@ -69,6 +69,7 @@ lazy val userProjects: Seq[ProjectReference] = List[ProjectReference]( httpSprayJson, httpXml, httpJackson, + httpJwt, httpScalafixRules, // don't aggregate tests for now as this will break with Scala compiler updates too easily ) lazy val aggregatedProjects: Seq[ProjectReference] = userProjects ++ List[ProjectReference]( @@ -335,6 +336,16 @@ lazy val httpJackson = .settings(Dependencies.httpJackson) .enablePlugins(ScaladocNoVerificationOfDiagrams) +lazy val httpJwt = project("akka-http-jwt") + .settings(commonSettings) + .settings(AutomaticModuleName.settings("akka.http.jwt")) + .addAkkaModuleDependency("akka-pki", "provided") + .addAkkaModuleDependency("akka-stream", "provided") + .addAkkaModuleDependency("akka-testkit", "provided") + .settings(Dependencies.httpJwt) + .dependsOn(http, httpCore, httpTestkit % "test") + .enablePlugins(BootstrapGenjavadoc) + lazy val httpCaching = project("akka-http-caching") .settings(commonSettings) .settings(AutomaticModuleName.settings("akka.http.caching")) @@ -437,7 +448,7 @@ lazy val docs = project("docs") .addAkkaModuleDependency("akka-stream-testkit", "provided", AkkaDependency.docs) .addAkkaModuleDependency("akka-actor-testkit-typed", "provided", AkkaDependency.docs) .dependsOn( - httpCore, http, httpXml, http2Tests, httpMarshallersJava, httpMarshallersScala, httpCaching, + httpCore, http, httpXml, http2Tests, httpMarshallersJava, httpMarshallersScala, httpCaching, httpJwt, httpTests % "compile;test->test", httpTestkit % "compile;test->test", httpScalafixRules % ScalafixConfig ) .settings(Dependencies.docs) diff --git a/docs/src/main/paradox/compatibility-guidelines.md b/docs/src/main/paradox/compatibility-guidelines.md index 89fdbf1567..25dcf4788b 100644 --- a/docs/src/main/paradox/compatibility-guidelines.md +++ b/docs/src/main/paradox/compatibility-guidelines.md @@ -117,6 +117,18 @@ Java akka.http.javadsl.settings.PoolImplementation akka.http.javadsl.settings.PreviewServerSettings ``` + +#### akka-http-jwt + +Scala +: ```scala + akka.http.jwt.scaladsl.JwtSettings + ``` + +Java +: ```java + akka.http.jwt.javadsl.JwtSettings + ``` ## Versioning and Compatibility diff --git a/docs/src/main/paradox/introduction.md b/docs/src/main/paradox/introduction.md index df308a12fc..92fc2d7701 100644 --- a/docs/src/main/paradox/introduction.md +++ b/docs/src/main/paradox/introduction.md @@ -253,3 +253,6 @@ Details can be found here: @ref[XML Support](common/xml-support.md) akka-http-jackson : Predefined glue-code for (de)serializing custom types from/to JSON with [jackson](https://github.com/FasterXML/jackson) @@@ + +akka-http-jwt +: Provides directives for validating and extracting JSON Web Tokens (JWT) from requests. Details can be found in the section @ref[JWT Directives](routing-dsl/directives/jwt-directives/jwt.md) \ No newline at end of file diff --git a/docs/src/main/paradox/routing-dsl/directives/alphabetically.md b/docs/src/main/paradox/routing-dsl/directives/alphabetically.md index 2d9b2f18b5..83baad612e 100644 --- a/docs/src/main/paradox/routing-dsl/directives/alphabetically.md +++ b/docs/src/main/paradox/routing-dsl/directives/alphabetically.md @@ -84,6 +84,7 @@ | @ref[headerValuePF](header-directives/headerValuePF.md) | Extracts an HTTP header value using a given @scala[`PartialFunction[HttpHeader, T]]`]@java[`PartialFunction`] | | @ref[host](host-directives/host.md) | Rejects all requests with a non-matching host name | | @ref[ignoreTrailingSlash](path-directives/ignoreTrailingSlash.md) | Retries the inner route adding (or removing) the trailing slash in case of empty rejections | +| @ref[jwt](jwt-directives/jwt.md) | Validates a JSON Web Token (JWT) from a request and extracts its claims for further processing | | @ref[listDirectoryContents](file-and-resource-directives/listDirectoryContents.md) | Completes GET requests with a unified listing of the contents of all given file-system directories | | @ref[logRequest](debugging-directives/logRequest.md) | Produces a log entry for every incoming request | | @ref[logRequestResult](debugging-directives/logRequestResult.md) | Produces a log entry for every incoming request and @apidoc[RouteResult] | diff --git a/docs/src/main/paradox/routing-dsl/directives/by-trait.md b/docs/src/main/paradox/routing-dsl/directives/by-trait.md index c7aa4fbab8..f8dd470c95 100644 --- a/docs/src/main/paradox/routing-dsl/directives/by-trait.md +++ b/docs/src/main/paradox/routing-dsl/directives/by-trait.md @@ -47,6 +47,9 @@ All predefined directives are organized into traits that form one part of the ov @ref[TlsDirectives](tls-directives/index.md) : Extract and require aspects of TLS/mTLS connections +@ref[JwtDirectives](jwt-directives/index.md) +: Require JWT token and extracts its claims + ## Directives creating or transforming the response @@ -101,6 +104,7 @@ All predefined directives are organized into traits that form one part of the ov * [future-directives/index](future-directives/index.md) * [header-directives/index](header-directives/index.md) * [host-directives/index](host-directives/index.md) +* [jwt-directives/index](jwt-directives/index.md) * [marshalling-directives/index](marshalling-directives/index.md) * [method-directives/index](method-directives/index.md) * [misc-directives/index](misc-directives/index.md) diff --git a/docs/src/main/paradox/routing-dsl/directives/jwt-directives/index.md b/docs/src/main/paradox/routing-dsl/directives/jwt-directives/index.md new file mode 100644 index 0000000000..b31e8363b5 --- /dev/null +++ b/docs/src/main/paradox/routing-dsl/directives/jwt-directives/index.md @@ -0,0 +1,9 @@ +# JwtDirectives + +@@toc { depth=1 } + +@@@ index + +* [jwt](jwt.md) + +@@@ \ No newline at end of file diff --git a/docs/src/main/paradox/routing-dsl/directives/jwt-directives/jwt.md b/docs/src/main/paradox/routing-dsl/directives/jwt-directives/jwt.md new file mode 100644 index 0000000000..0dbc88afea --- /dev/null +++ b/docs/src/main/paradox/routing-dsl/directives/jwt-directives/jwt.md @@ -0,0 +1,52 @@ +# jwt + +@@@ div { .group-scala } + +## Signature + +@@signature [JwtDirectives.scala](/akka-http-jwt/src/main/scala/akka/http/jwt/scaladsl/server/directives/JwtDirectives.scala) { #jwt } + +@@@ + +## Description + +This directive provides a way to validate a JSON Web Token (JWT) from a request and extracts its claims for further processing. For details on what a valid JWT is, see [jwt.io](https://jwt.io/) or consult [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519). + +JWTs are validated against a predefined secret or public key, depending on the used algorithm, and provided by configuration. The directive uses config defined under `akka.http.jwt`, or an explicitly provided `JwtSettings` instance. + +## Dependency + +The Akka dependencies are available from Akka's library repository. To access them there, you need to configure the URL for this repository. + +@@repository [sbt,Gradle,Maven] { +id="akka-repository" +name="Akka library repository" +url="https://repo.akka.io/maven" +} + +To use Akka HTTP Caching, add the module to your project: + +@@dependency [sbt,Gradle,Maven] { +bomGroup2="com.typesafe.akka" bomArtifact2="akka-http-bom_$scala.binary.version$" bomVersionSymbols2="AkkaHttpVersion" +symbol="AkkaHttpVersion" +value="$project.version$" +group="com.typesafe.akka" +artifact="akka-http-jwt_$scala.binary.version$" +version="AkkaHttpVersion" +} + +## Example + +The `jwt` directive will extract and validate a JWT from the request and provide the extracted claims to the inner route in the format of a `JwtClaims` instance, which offers utility methods to extract a specific claims: + +Scala +: @@snip [JwtDirectivesExamplesSpec.scala](/docs/src/test/scala/docs/http/scaladsl/server/directives/JwtDirectivesExamplesSpec.scala) { #jwt } + +Java +: @@snip [JwtDirectivesExamplesTest.java](/docs/src/test/java/docs/http/javadsl/server/directives/JwtDirectivesExamplesTest.java) { #jwt } + + + +## Reference configuration + +@@snip [reference.conf](/akka-http-jwt/src/main/resources/reference.conf) { #jwt } \ No newline at end of file diff --git a/docs/src/test/java/docs/http/javadsl/server/directives/JwtDirectivesExamplesTest.java b/docs/src/test/java/docs/http/javadsl/server/directives/JwtDirectivesExamplesTest.java new file mode 100644 index 0000000000..30c353cde5 --- /dev/null +++ b/docs/src/test/java/docs/http/javadsl/server/directives/JwtDirectivesExamplesTest.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2023 Lightbend Inc. + */ + +package docs.http.javadsl.server.directives; + +import akka.http.javadsl.server.AuthorizationFailedRejection; +import akka.http.javadsl.server.Directives; +import akka.http.javadsl.server.Route; +import akka.http.jwt.javadsl.server.directives.JwtDirectives; + +public class JwtDirectivesExamplesTest extends JwtDirectives { + + public void compileOnlySpecJwt() throws Exception { + //#jwt + final Route route = jwt(claims -> { + if (claims.getStringClaim("sub").isPresent()) + return Directives.complete(claims.getStringClaim("sub").get()); + else + return Directives.reject(AuthorizationFailedRejection.get()); + } + ); + //#jwt + } +} diff --git a/docs/src/test/scala/docs/http/scaladsl/server/directives/JwtDirectivesExamplesSpec.scala b/docs/src/test/scala/docs/http/scaladsl/server/directives/JwtDirectivesExamplesSpec.scala new file mode 100644 index 0000000000..1632f0e923 --- /dev/null +++ b/docs/src/test/scala/docs/http/scaladsl/server/directives/JwtDirectivesExamplesSpec.scala @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2009-2023 Lightbend Inc. + */ + +package docs.http.scaladsl.server.directives + +import akka.http.jwt.scaladsl.server.directives.JwtDirectives.jwt +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.server.{ AuthorizationFailedRejection, RoutingSpec } +import docs.CompileOnlySpec + +import java.util.Base64 + +class JwtDirectivesExamplesSpec extends RoutingSpec with CompileOnlySpec { + + override def testConfigSource = + s""" + akka.loglevel = DEBUG + akka.http.jwt { + dev = off + secrets: [ + { + key-id: my-key + issuer: my-issuer + algorithm: HS256 + secret: "${Base64.getEncoder.encodeToString("my-secret".getBytes)}" + } + ] + } + """ + + "jwt" in { + //#jwt + val route = + jwt() { + _.stringClaim("role") match { + case Some("admin") => complete(s"You're in!") + case _ => reject(AuthorizationFailedRejection) + } + } + + // tests: + + // regular request + + // manually injected valid JWT for test purposes with a claim "role" -> "admin" + val jwtToken = Authorization(OAuth2BearerToken( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6ImFkbWluIn0.6JBvEPNY4KVZpZYfoG6y5UOh3RLUbG-kPyxKHim_La8")) + + Get() ~> addHeader(jwtToken) ~> route ~> check { + responseAs[String] shouldEqual "You're in!" + } + + //#jwt + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index ce8bece81a..8177a86e05 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -48,6 +48,7 @@ object Dependencies { // For akka-http spray-json support val sprayJson = "io.spray" %% "spray-json" % "1.3.6" // ApacheV2 + val jwtScala = "com.github.jwt-scala" %% "jwt-json-common" % "10.0.0" // ApacheV2 // For akka-http-jackson support val jacksonDatabind = "com.fasterxml.jackson.core" % "jackson-databind" % jacksonDatabindVersion // ApacheV2 @@ -138,6 +139,12 @@ object Dependencies { libraryDependencies += Test.scalatest ) + lazy val httpJwt = Seq( + versionDependentDeps(jwtScala), + versionDependentDeps(sprayJson), + libraryDependencies += Test.scalatest + ) + lazy val httpJackson = l ++= Seq(jacksonDatabind, Test.scalatestplusJUnit, Test.junit, Test.junitIntf) lazy val docs = l ++= Seq(Docs.sprayJson, Docs.gson, Docs.jacksonXml, Docs.reflections) diff --git a/project/MiMa.scala b/project/MiMa.scala index d0da79d3ab..152e2d14a7 100644 --- a/project/MiMa.scala +++ b/project/MiMa.scala @@ -62,9 +62,11 @@ object MiMa extends AutoPlugin { override val projectSettings = Seq( mimaPreviousArtifacts := { - val versions = - if (scalaBinaryVersion.value == "3") post3Versions + val versions = { + if (name.value == "akka-http-jwt") Set.empty // FIXME remove this once we have a release of akka-http-jwt + else if (scalaBinaryVersion.value == "3") post3Versions else pre3Versions ++ post3Versions + } versions.map { version => organization.value %% name.value % version