Using JWTs

This section describes the practical aspects of using JSON Web Tokens (JWTs) with the Java SDK, if you are not sure what JWTs are, how they work or how to generate them, see JSON Web Tokens first.

Kalix’s JWT support is configured by placing annotations on methods in your endpoints or at the component class level.

Authentication

Kalix can validate the signature of JWT tokens provided in an Authorization header to grant access to your endpoints. Typically, these tokens will be generated in response to an authentication request, e.g. by supplying refresh token, or perhaps a username and password. These tokens may be generated by a third party service such as Auth0. You can find more information about the generating tokens here.

Bearer token validation

If you want to validate the bearer token of a request, you need to annotate your endpoint like:

@PostMapping("/message")
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN) (1)
public Action.Effect<String> message(@RequestBody String msg) {
    return effects().reply(msg);
}
1 Validating the Bearer is present in the Authorization header.

Only requests that have a bearer token that can be validated by one of the configured keys for the service will be allowed, all other requests will be rejected. The bearer token must be supplied with requests using the Authorization header, like:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

If you want to assert that only tokens from a particular issuer are allowed, that be can be done using the bearerTokenIssuer option:

@PostMapping("/message")
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN,
     bearerTokenIssuer = "my-issuer")       (1)
public Action.Effect<String> messageWithIssuer(@RequestBody String msg) {
    return effects().reply(msg);
}
1 The token extracted from the bearer token must have this issuer.

It is recommended that you use this in conjunction with specifying an issuer in your JWT key configuration. Otherwise, any of the services whose keys you trust may spoof the issuer.

Multiple issuers may be allowed, by setting multiple bearer_token_issuer values:

@PostMapping("/message")
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN,
     bearerTokenIssuer = {"my-issuer", "my-other-issuer"}) (1)
public Action.Effect<String> messageWithMultiIssuer(@RequestBody String msg) {
    return effects().reply(msg);
}
1 The token extracted from the bearer token must have one of the two issuers defined in the annotation.

Kalix will place the claims from the validated token into the request metadata, so you can access it from your service. All claims are prefixed with _kalix-jwt-claim-, so for example, if you want to read the subject claim, you can read the metadata entry _kalix-jwt-claim-sub. String claims are passed unquoted. All other claim types, including arrays and objects, are passed using their JSON encoding. Below you can see an example on how to extract a "subject" claim:

@PostMapping("/message")
@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN)
public Action.Effect<String> messageWithClaimValidation(@RequestBody String msg) {
    var maybeSubject = actionContext().metadata().jwtClaims().subject();
    if (maybeSubject.isEmpty())
        return effects().error("No subject present", StatusCode.ErrorCode.UNAUTHORIZED);

    return effects().reply(msg);
}

Configuring JWT at component level

The above examples show how to configure a JWT token on a method.

JWT configurations can also be shared between all methods of a component by specifying them on the class. The annotation will then be applied to all methods in that component. Method-level annotations take precedence over class-level annotations.

@JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN,
     bearerTokenIssuer = "my-issuer") (1)
public class HelloJwtAction extends Action {

    @PostMapping("/message") (2)
    public Action.Effect<String> message(@RequestBody String msg) {
        //..
    }

    @PostMapping("/message/issuer")
    @JWT(validate = JWT.JwtMethodMode.BEARER_TOKEN,
         bearerTokenIssuer = "my-other-issuer")
    public Action.Effect<String> messageWithIssuer(@RequestBody String msg) { (3)
        //..
    }
}
1 A type level JWT annotation is added to the class requiring a bearer token issuer named my-issuer.
2 The method message inherits the JWT configuration from the class-level annotation and therefore will require a token issued by my-issuer.
3 The method messageWithIssuer overwrites the class-level annotation and requires a different token issuer, i.e.: my-other-issuer.

Using static claims

When the values of specific claims are known in advance, Kalix can be configured to automatically require and validate them. Multiple static claims can be declared and environment variables are supported on the value field. Static claims can be defined both at service and method level. The provided claims will be used when validating against the bearer token.

@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.
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:

@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.

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

If you wish to set the issuer for this dev key, you can do that modifying the docker-compose.yml file in your project, setting the JWT_DEV_SECRET_ISSUER environment variable in the kalix-runtime service:

version: "3"
services:
  kalix-runtime:
    ...
    environment:
      JWT_DEV_SECRET_ISSUER: "my-issuer"
      ...

JWTs when running integration tests

When running integration tests, JWTs will still be enforced but its signature will not be validated, similarly to what is described above for when running locally. Thus, when making calls in the context of integration testing, make sure to inject a proper token with the required claims, as shown below:

@Test
public void testMsgWithClaim() throws Exception {
  String bearerToken = bearerTokenWith( (1)
      Map.of("iss", "my-issuer", "sub", "hello"));

  var msg = "Hello from integration test";
  var response = componentClient
      .forAction().call(JWTAction::messageWithClaimValidation).params(msg)
      .withMetadata( (2)
          Metadata.EMPTY.add("Authorization", "Bearer " + bearerToken))
      .execute()
      .toCompletableFuture()
      .get();

  assertThat(response).contains(msg);
}

private String bearerTokenWith(Map<String, String> claims) throws JsonProcessingException {
  // setting algorithm to none
  String alg = Base64.getEncoder().encodeToString("""
      {
        "alg": "none"
      }
      """.getBytes()); (3)
  byte[] jsonClaims = new ObjectMapper().writeValueAsBytes(claims);

  // no validation is done for integration tests, thus no valid signature required
  return alg + "." +  Base64.getEncoder().encodeToString(jsonClaims); (4)
}
1 Use a helper method to create a token with 2 claims: issuer and subject.
2 Inject the bearer token as metadata with key Authorization.
3 Use static Base64 encoding of { "alg": "none" }.
4 Note that we do not need to provide a signature, thus the token has only 2 parts.