Using JWTs
This section describes the practical aspects of configuring JSON Web Tokens (JWTs) with the Java/Scala Protobuf SDKs, 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 and messages in your service’s protobuf descriptor.
Authentication
Bearer token validation
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.
If you want Kalix to validate the bearer token of a request, this can be done by annotating the gRPC method:
rpc MyMethod(MyRequest) returns (MyResponse) {
option (kalix.method).jwt = {
validate: BEARER_TOKEN
};
};
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 bearer_token_issuer
option:
rpc MyMethod(MyRequest) returns (MyResponse) {
option (kalix.method).jwt = {
validate: BEARER_TOKEN
bearer_token_issuer: "my-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:
rpc MyMethod(MyRequest) returns (MyResponse) {
option (kalix.method).jwt = {
validate: BEARER_TOKEN
bearer_token_issuer: "my-issuer"
bearer_token_issuer: "my-other-issuer"
};
};
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 header _kalix-jwt-claim-sub
. String claims are passed unquoted. All other claim types, including arrays and objects, are passed using their JSON encoding.
Configuring JWT at gRPC service 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 service by specifying them at service level. The annotation will then be applied to all methods in that service. Method-level annotations take precedence over service-level annotations.
service JwtSpecService {
option (kalix.service).jwt = {
validate: BEARER_TOKEN
bearer_token_issuer: "my-issuer" (1)
};
rpc Message(google.protobuf.Empty) returns (google.protobuf.Empty) (2)
rpc MessageWithIssuer(google.protobuf.Empty) returns (google.protobuf.Empty) {
option (kalix.method).jwt = {
validate: BEARER_TOKEN
bearer_token_issuer: "my-other-issuer" (3)
};
}
}
1 | A service level JWT annotation is added to the service requiring a bearer token issuer named my-issuer . |
2 | The method Message inherits the JWT configuration from the service level annotation and therefore will require a token issued by my-issuer . |
3 | The method MessageWithIssuer overwrites the service level annotation and requires a different token issuer, i.e.: my-other-issuer . |
Authorization
Message validation and signing
Tokens may also be generated by Kalix itself using the message signing feature. Kalix supports both validation and signing of messages. This is done through annotations that indicate which fields in a message contain JWT tokens, and which fields in a message contain claims that should be included in the token when signing, or validated against the claims in the token when validating.
Annotating gRPC methods
In order for Kalix to know which methods should have their incoming messages validated, and which should have their outgoing messages signed, a method level annotation is required. If you want to validate an incoming message:
rpc MyMethod(MyRequest) returns (MyResponse) {
option (kalix.method).jwt = {
validate: MESSAGE
};
};
If you want to sign an outgoing message:
rpc MyMethod(MyRequest) returns (MyResponse) {
option (kalix.method).jwt = {
sign: MESSAGE
};
};
Note that validation and signing of messages and bearer tokens may be mixed, for example:
rpc MyMethod(MyRequest) returns (MyResponse) {
option (kalix.method).jwt = {
validate: BEARER_TOKEN
validate: MESSAGE
sign: MESSAGE
};
};
Configuring the token location
When signing, the token will be placed into a field configured in the message. When validating, the token can either be extracted from a field in the message, or the bearer token may be used.
Annotating token fields
A field may be marked as containing a token using the following annotation:
string my_token_field = 1 [(kalix.field).jwt = {
token: true
}];
The field must either be of type string or bytes, string is recommended. Only one string/bytes annotated token field is allowed per message. When signing, a JWT with claims extracted from the message (according to the claim annotations described later) will be written to this field before being sent to the client. When validating, a JWT will be extracted from this field, its signature will be validated, and then the claims will be validated according to the claim annotations in the message. If no token is present, or if any part of the validation fails, the request will be rejected.
Validating against the bearer token
To validate a message against the bearer token, rather than extracting the token from the message itself, the validate_bearer_token
annotation may be set on the message:
message MyRequest {
option (kalix.message).jwt = {
validate_bearer_token: true
};
}
Note that when validating against the bearer token, the gRPC method being validated must be annotated to validate both the bearer token and the message, like so:
rpc MyMethod(MyRequest) returns (MyResponse) {
option (kalix.method).jwt = {
validate: BEARER_TOKEN
validate: MESSAGE
};
};
Setting token expiry
By default, all tokens generated by Kalix have an expiry of one hour (3600 seconds). This can be customised using the expires_seconds
option:
string my_token_field = 1 [(kalix.field).jwt = {
token: true
expires_seconds: 300
}];
Setting the value to -1 instructs Kalix to not set an expiry claim.
Using static claims
When the values of specific claims are known in advance, Kalix can be configured to automatically require and validate them. When signing, it will automatically add the provided static claims to the generated token. 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.
string my_token_field = 1 [(kalix.field).jwt = {
token: true
static_claim: {
claim: "role"
value: ["admin", "editor"] (1)
}
static_claim: {
claim: "aud"
value: "${ENV_CLAIM_VALUE}.kalix.io" (2)
}
}];
1 | When declaring multiple values for the same claim, all of them will be required when validating the request. In this case, both admin and editor are required. |
2 | The value of the claim can be dependent on an environment variable, which will be resolved at runtime. |
Static claims can be defined both at service and method level. In the below example, the provided claims will be used when validating against a bearer token.
service JwtSpecService {
option (kalix.service).jwt = {
validate: BEARER_TOKEN
static_claim: {
claim: "aud"
value: "${ENV_CLAIM_VALUE}.kalix.io"
}
};
// ...
}
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:
service JwtSpecService {
option (kalix.service).jwt = {
validate: BEARER_TOKEN
static_claim: {
claim: "role" (1)
pattern: "^(admin|editor)$"
}
static_claim: {
claim: "sub" (2)
pattern: "^[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}$"
}
static_claim: {
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. |
If the JWT token claim is an array of values, the token will be considered valid if at least one of the claim values matches the pattern. Otherwise, the request is rejected.
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.
|
Signing and validating nested messages
If you want to sign or validate a nested message, you may do this by annotating it with the token annotation:
MyChildMessage my_child_message = 1 [(kalix.field).jwt = {
token: true
}];
This indicates that the signing and validation should descend into this message. The message must have at least one token annotated field itself, and may recurse further. Repeated message fields may also be signed and validated:
repeated MyChildMessage my_child_messages = 1
[(kalix.field).jwt = {
token: true
}];
Specifying fields to be signed or validated
A message with a token is not very useful if that token doesn’t assert any claims. Any field of a message can be included as a claim using the claim annotation:
string my_claim_field = 1 [(kalix.field).jwt = {
claim: INCLUDE
}];
When signing, this will instruct Kalix to extract the value from my_claim_field
and set it as a claim in the JWT. The name of the claim will be my_claim_field
. Claims can be of almost any type. The only unsupported type is maps that don’t have string keys. Message types will be serialised to JSON objects, repeated fields will be serialised to JSON arrays, bytes fields will be serialised to strings using Base64 encoding. If the value in the field is the zero value for that type, that is, if the field is empty, no claim will be extracted and included in the token.
When validating, Kalix will extract the value from my_claim_field
, and ensure that it matches a corresponding claim in the JWT. If the claim doesn’t exist in the JWT, validation will fail. If the claim does exist, but has a different value, validation will fail. If the value of the field is the zero value for its type, no validation will be done, even if that claim exists in the JWT. An important thing to note here is that this means that boolean claims are only validated if they are true. If they are false, no validation is done, since false is the empty value for boolean.
A custom name for the claim may be specified:
string my_claim_field = 1 [(kalix.field).jwt = {
claim: INCLUDE
name: "my-claim"
}];
This will be used both during validation and signing.
Including claims from child messages
A claim may be included from a child message by annotating that message with the claim descend annotation:
MyChildMessage my_child_message = 1 [(kalix.field).jwt = {
claim: DESCEND
}];
This instructs Kalix to descend into that message and extract fields according to the claim annotations in that message, and use them as claims in the JWT for this message. The claims will appear as top level claims in the JWT. Only non repeated messages may have a descend claim annotation on them. Descend claims can recurse multiple messages deep, however, they must not recurse cyclically (where a descended message descends directly or indirectly into itself). If this is detected, an error will be raised when the service is started.
Nesting claims from child messages
If you set a message field to INCLUDE, that entire message will be serialised to a JSON object and included as a claim. If you want to control how that message is serialised, you can instead use the NEST claim annotation:
MyChildMessage my_child_message = 1 [(kalix.field).jwt = {
claim: NEST
}];
This instructs Kalix to descend into that message and extract fields according to the claim annotations in that message, and include them inside an object as a single claim in this message’s JWT. This differs from DESCEND in that if you extract two fields, foo
and bar
from the child message, in a NEST claim, they will appear in the JWT claims like this:
{
"my_child_message": {
"foo": "value",
"bar": "value"
},
"some_other_claim_field": "value"
}
Whereas when using DESCEND, they will appear in the JWT claims like this, unnested:
{
"foo": "value",
"bar": "value",
"some_other_claim_field": "value"
}
The name of the wrapping claim may be customized using the name
option. Nested claims must be messages, and can be repeated. Nested claims may recurse multiple messages deep, however, they must not recurse cyclically (where a nested message directly or indirectly nests or descends into itself). If this is detected, an error will be raised when the service is started.
Including and validating an issuer
You can require that a JWT from a message has a particular issuer, using the issuer option:
string my_token_field = 1 [(kalix.field).jwt = {
token: true,
issuer: "my-issuer"
}];
When validating, this will require that the issuer claim (iss) matches the given value. When signing, it will assert the given value as an issuer claim. This will also influence which configured keys are used to validate/sign the token.
You can also specify multiple issuers:
string my_token_field = 1 [(kalix.field).jwt = {
token: true
issuer: "my-issuer"
issuer: "my-other-issuer"
}];
When validating, it will validate that the issuer matches one of the configured issuers. When signing, the first issuer will be used.
Extracting values from claims
When validating, you can instruct Kalix to extract claims from a JWT, and write them into the incoming message, like so:
string my_claim_field = 1 [(kalix.field).jwt = {
claim: EXTRACT
}];
If the field is present on the incoming message, the incoming value will still be validated, it won’t be overwritten by the extracted value. EXTRACT claims have the same effect as INCLUDE claims when signing.
Including claims from a bearer token
Sometimes it may make sense to include claims from an incoming bearer token when validating or signing a token in a message - for example, if you want to ensure that only the currently authenticated user may use the token. This can be done using the include_bearer_token_claim
option on token annotated fields:
string my_token_field = 1 [(kalix.field).jwt = {
token: true,
include_bearer_token_claim: "sub"
}];
When signing, this will extract the subject claim from the bearer token, and include it in the message token. When validating, this will extract the subject claim from the bearer token, and validate that it matches the subject claim in the message token. If the bearer token is not present, the claim is not present in the bearer token, or the claim is not present in the message token, validation will fail. Bearer token claims can only be included if the method is annotated with validate: BEARER_TOKEN
.
Ad-hoc claims
You may not want to model all your claims in your protobuf message structure. In that case, you can use raw fields:
map<string, string> my_raw_claims = 1 [(kalix.field).jwt = {
claim: RAW
}];
Raw claims are string keyed maps, with each entry in the map being a claim named according to the key. The value in the map can be anything, and will be serialised to JSON. When signing, the claims will be extracted from the field and included in the token, and the raw claim field will be cleared from the message before sending. When validating, all claims that match the type of the values in the map will be extracted into the map - any values already in the map will be cleared. Multiple RAW annotated fields can be used for extracting and signing raw claims of different types, for example, strings, numbers, messages.
Including claims from parent messages in tokens
Tokens in nested messages may also include claims made by their parent message, by annotating the token in the nested message with include_parent_claims
:
string my_token_field = 1 [(kalix.field).jwt = {
token: true,
include_parent_claims: true
}];
This may be useful if you want a token for a message to appear in a child message, rather than at the top level, or if you have a set of repeated messages with tokens in them, and there are some claims that you want included in each of the message’s tokens that come from the parent and that you don’t want to duplicate in the child.
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. But the tokens still contain all the claims, and messages are validated against those claims, so you can still effectively verify that JWT messaging support is working. The lack of signature means that tokens can trivially be forged, but this is not usually an issue when testing locally.
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:
- Java
-
src/it/java/com/example/JwtIntegrationTest.java
private final JwtServiceActionClient client; public JwtIntegrationTest() { client = (JwtServiceActionClient) testKit.getGrpcClient(CounterService.class); (1) } @Test public void testMsgWithClaim() throws Exception { String bearerToken = bearerTokenWith( (2) Map.of("iss", "my-issuer", "sub", "hello")); var msg = "Hello from integration test"; var req = JwtService.MyRequest.newBuilder().setMsg(msg).build(); var response = client.jwtInToken() .addHeader("Authorization", "Bearer " + bearerToken) (3) .invoke(req) .toCompletableFuture() .get(); assertTrue(response.getMsg().contains(msg)); } private String bearerTokenWith(Map<String, String> claims) throws JsonProcessingException { // setting algorithm to none String alg = Base64.getEncoder() .encodeToString("{\"alg\":\"none\"}".getBytes()); (4) byte[] jsonClaims = new ObjectMapper().writeValueAsBytes(claims); // no validation is done for integration tests, thus no valid signature required return alg + "." + Base64.getEncoder().encodeToString(jsonClaims); (5) }
1 Cast from JwtServiceAction
toJwtServiceActionClient
. This is required to be able to inject headers in the request, but it’s a safe cast.2 Use a helper method to create a token with 2 claims: issuer and subject. 3 Inject the bearer token as metadata with key Authorization
.4 Use static Base64
encoding of{ "alg": "none" }
.5 Note that we do not need to provide a signature, thus the token has only 2 parts. - Scala
-
src/test/scala/com/example/JwtIntegrationSpec.scala
private val client = testKit.getGrpcClient(classOf[JwtServiceAction]) .asInstanceOf[JwtServiceActionClient] (1) "JwtServiceAction" must { "accept requests with a valid bearer token passed as metadata" in { val token = bearerTokenWith(Map("iss" -> "my-issuer", "sub"-> "hello")) (2) val msg = "hello from integration test" val response = client.jwtInToken() .addHeader("Authorization", "Bearer " + token) (3) .invoke(MyRequest(msg)) response.futureValue.msg should include(msg) } } private def bearerTokenWith(claims: Map[String, String]): String = { // setting algorithm to none val alg = Base64.getEncoder.encodeToString(s"""{"alg":"none"}""".getBytes); (4) import spray.json.DefaultJsonProtocol._ val claimsJson = s"${claims.toJson}" // no validation is done for integration tests, thus no valid signature required s"$alg.${Base64.getEncoder.encodeToString(claimsJson.getBytes)}" (5) }
1 Cast from JwtServiceAction
toJwtServiceActionClient
. This is required to be able to inject headers in the request, but it’s a safe cast.2 Use a helper method to create a token with 2 claims: issuer and subject. 3 Inject the bearer token as metadata with key Authorization
.4 Use static Base64
encoding of{ "alg": "none" }
.5 Note that we do not need to provide a signature, thus the token has only 2 parts.