Skip to content

Commit

Permalink
feat(java-sdk): support for pattern in jwt static claim (#1926)
Browse files Browse the repository at this point in the history
* feat(java-sdk): support for pattern in jwt static claim

* fix vale quotes

* phrase clarification
  • Loading branch information
efgpinto authored Jan 10, 2024
1 parent e50ea81 commit c0ee3b5
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 22 deletions.
32 changes: 27 additions & 5 deletions docs/src/modules/java/pages/using-jwts.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,39 @@ When the values of specific claims are known in advance, Kalix can be configured

[source, java]
----
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN,
bearerTokenIssuer = "my-issuer",
staticClaims = {
@JWT.StaticClaim(claim = "role", value = {"admin", "editor"}), // <1>
@JWT.StaticClaim(claim = "aud", value = "${ENV}.kalix.io")}) // <2>
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN,
bearerTokenIssuer = "my-issuer",
staticClaims = {
@JWT.StaticClaim(claim = "role", value = {"admin", "editor"}), // <1>
@JWT.StaticClaim(claim = "aud", value = "${ENV}.kalix.io")}) // <2>
----
<1> When declaring multiple values for the same claim, **all** of them will be required when validating the request.
<2> The value of the claim can be dependent on an environment variable, which will be resolved at runtime.

NOTE: For specifying an issuer claim (i.e. "iss"), you should still use the provided issuer-specific fields and not static claims.

==== Configuring static claims with a pattern

Static claims can also be defined using a pattern. This is useful when the value of the claim is not completely known in advance, but it can still be validated against a regular expression. See some examples below:

[source, java]
----
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN,
staticClaims = {
@JWT.StaticClaim(claim = "role", pattern = "^(admin|editor)$"), // <1>
@JWT.StaticClaim(claim = "sub", pattern = // <2>
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"),
@JWT.StaticClaim(claim = "name", pattern = "^\\S+$") // <3>
})
----
<1> Claim "role" must have one of 2 values: `admin` or `editor`.
<2> Claim "sub" must be a valid UUID.
<3> Claim "name" must be not empty.

When signing a JWT, static claim defined by a pattern will not be included. They are only used in validation.

NOTE: A static claim can be defined with a `value` or a `pattern`, but not both.

== Running locally with JWT support

When running locally using docker compose, by default, a dev key with id `dev` is configured for use. This key uses the JWT `none` signing algorithm, which means the JWT tokens produced do not contain a cryptographic signature, nor are they validated against a signature when validating. Therefore, when calling an endpoint with a bearer token, only its presence is validated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ enum JwtMethodMode {
String[] bearerTokenIssuer() default {};


/**
* A static claim is a claim that is required to be present on the token, and have a particular
* value. This can be used to ensure that the token has a particular role, for example.
* <p>
* If the claim is not present, or does not have the expected value, then the request will be
* rejected with a 403 Forbidden response.
* <p>
* If the claim is present, but does not have the expected value, then the request will be
* rejected with a 403 Forbidden response.
* <p>
* If the claim is present, and has the expected value, then the request will be allowed to
* proceed.
*
* Each static claim can be configured either with a 'value' or a 'pattern' that will
* be matched against the value of the claim, but not both.
*/
@interface StaticClaim {
/**
* The claim name needs to be a hardcoded literal (e.g. "role")
Expand All @@ -61,7 +77,20 @@ enum JwtMethodMode {
* or a combination of both (e.g. "${ENV_VAR}-admin").
* When declaring multiple values, ALL of those will be required when validating the claim.
*/
String[] value();
String[] value() default {};

/**
* This receives a regex expression (Java flavor) used to match on the incoming claim value.
* Cannot be used in conjunction with `value` field above. It's one or the other.
* <p>NOTE: when signing, a static claim defined with a pattern will not be included in the token.
*
* <p>Usage examples:
* <ul>
* <li>claim value is not empty: "\\S+"</li>
* <li>claim value has one of 2 possible values: "^(admin|manager)$"</li>
* </ul>
*/
String pattern() default "";
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ import scala.jdk.CollectionConverters.IterableHasAsJava

object JwtDescriptorFactory {

private def buildStaticClaimFromAnnotation(sc: JWT.StaticClaim): JwtStaticClaim =
JwtStaticClaim
.newBuilder()
.setClaim(sc.claim())
.addAllValue(sc.value().toList.asJava)
.setPattern(sc.pattern())
.build()

private def jwtMethodOptions(javaMethod: Method): JwtMethodOptions = {
val ann = javaMethod.getAnnotation(classOf[JWT])
val jwt = JwtMethodOptions.newBuilder()
Expand All @@ -38,8 +46,7 @@ object JwtDescriptorFactory {

ann
.staticClaims()
.foreach(sc =>
jwt.addStaticClaim(JwtStaticClaim.newBuilder().setClaim(sc.claim()).addAllValue(sc.value().toList.asJava)))
.foreach(sc => jwt.addStaticClaim(buildStaticClaimFromAnnotation(sc)))
jwt.build()
}

Expand Down Expand Up @@ -69,8 +76,7 @@ object JwtDescriptorFactory {
ann.bearerTokenIssuer().map(jwt.addBearerTokenIssuer)
ann
.staticClaims()
.foreach(sc =>
jwt.addStaticClaim(JwtStaticClaim.newBuilder().setClaim(sc.claim()).addAllValue(sc.value().toList.asJava)))
.foreach(sc => jwt.addStaticClaim(buildStaticClaimFromAnnotation(sc)))

val kalixServiceOptions = kalix.ServiceOptions.newBuilder()
kalixServiceOptions.setJwt(jwt.build())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ public static class ActionWithMethodLevelJWT extends Action {
validate = JWT.JwtMethodMode.BEARER_TOKEN,
bearerTokenIssuer = {"a", "b"},
staticClaims = {
@JWT.StaticClaim(claim = "role", value = "admin"),
@JWT.StaticClaim(claim = "roles", value = {"viewer", "editor"}),
@JWT.StaticClaim(claim = "aud", value = "${ENV}.kalix.io"),
@JWT.StaticClaim(claim = "more-roles", value = {"viewer", "editor"})
@JWT.StaticClaim(claim = "sub", pattern = "^sub-\\S+$")
})
public Action.Effect<Message> message(@RequestBody Message msg) {
return effects().reply(msg);
Expand All @@ -121,9 +121,9 @@ public Action.Effect<Message> message(@RequestBody Message msg) {
validate = JWT.JwtMethodMode.BEARER_TOKEN,
bearerTokenIssuer = {"a", "b"},
staticClaims = {
@JWT.StaticClaim(claim = "role", value = "admin"),
@JWT.StaticClaim(claim = "roles", value = {"editor", "viewer"}),
@JWT.StaticClaim(claim = "aud", value = "${ENV}.kalix.io"),
@JWT.StaticClaim(claim = "more-roles", value = {"editor", "viewer"})
@JWT.StaticClaim(claim = "sub", pattern = "^\\S+$")
})
public static class ActionWithServiceLevelJWT extends Action {
@PostMapping("/message")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,12 @@ class ActionDescriptorFactorySpec extends AnyWordSpec with ComponentDescriptorSu
assertRequestFieldJavaType(method, "json_body", JavaType.MESSAGE)

val Seq(claim1, claim2, claim3) = jwtOption.getStaticClaimList.asScala.toSeq
claim1.getClaim shouldBe "role"
claim1.getValue(0) shouldBe "admin"
claim1.getClaim shouldBe "roles"
claim1.getValueList.asScala.toSeq shouldBe Seq("viewer", "editor")
claim2.getClaim shouldBe "aud"
claim2.getValue(0) shouldBe "${ENV}.kalix.io"
claim3.getClaim shouldBe "more-roles"
claim3.getValueList.asScala.toSeq shouldBe Seq("viewer", "editor")
claim3.getClaim shouldBe "sub"
claim3.getPattern shouldBe "^sub-\\S+$"
}
}

Expand All @@ -245,12 +245,12 @@ class ActionDescriptorFactorySpec extends AnyWordSpec with ComponentDescriptorSu
jwtOption.getValidate shouldBe JwtServiceMode.BEARER_TOKEN

val Seq(claim1, claim2, claim3) = jwtOption.getStaticClaimList.asScala.toSeq
claim1.getClaim shouldBe "role"
claim1.getValue(0) shouldBe "admin"
claim1.getClaim shouldBe "roles"
claim1.getValueList.asScala.toSeq shouldBe Seq("editor", "viewer")
claim2.getClaim shouldBe "aud"
claim2.getValue(0) shouldBe "${ENV}.kalix.io"
claim3.getClaim shouldBe "more-roles"
claim3.getValueList.asScala.toSeq shouldBe Seq("editor", "viewer")
claim3.getClaim shouldBe "sub"
claim3.getPattern shouldBe "^\\S+$"
}
}

Expand Down

0 comments on commit c0ee3b5

Please sign in to comment.