Validation of JSON Web tokens on resource servers. Components:
- spring - Spring Boot integration (of the above) via Spring Security.
Spring Boot starter for Json Web Token (JWT) authorization.
Functional overview:
- Get Json Web Keys from the Authorization Server; use them to
- verify JWT signatures in incoming requests, then
- enforce generic JWT claim constraints (like checking audience, timestamp etc), then
- parse authorities (permissions) and populate the security context,
- let Spring Security enforce access per method (
@EnableGlobalMethodSecurity(prePostEnabled = true)
- optionally injects transformed JWT security context into downstream method signatures
Also supports a few typical CORS settings. A custom WebSecurityConfigurerAdapter
can be provided by the application.
The default implementation supports configuration of one or more tenants.
- tenant id (key for configuration)
- issuer
- JSON Web Key location
Claim validation (common for all tenants) can also be added:
- audiences
- expires-at leeway
- issued-at leeway
- and more
For YAML, this amounts to something like
entur:
jwt:
enabled: true # note: false is the default value
tenants:
myKeycloak: # i.e. tenant id
issuer: https://myRealm.keycloak.com
jwk:
location: https://my.keycloak.com/auth/realms/myRealm/protocol/openid-connect/certs
claims:
expires-at-leeway: 15
issued-at-leeway: 5
audiences: # at least one
- https://my.audience
By default, all requests must be so-called fully authenticated. In other words all requests must have a valid JWT token (from any of the configured tenants).
Open endpoints (i.e. permitted for all, open to the world) must be explicitly configured (white-listed) using MVC or Ant matchers.
Example for open endpoints:
entur:
authorization:
enabled: true # note: this is the default value
permit-all:
matcher:
patterns:
- /unprotected/**
matcher:
patterns:
- /some/path/{myVariable}
Note that Spring Web uses MVC matchers. In other words, for a @RestController
with a method
@GetMapping(value = "/open/country/{countryCode}")
public String authenticatedEndpoint(){
// your code here
}
add the MVC matcher /open/country/{countryCode}
. Optionally also specify the HTTP method using
entur:
authorization:
permit-all:
matcher:
method:
get:
patterns:
- /some/path/{myVariable}
MVC matchers are in general broader than Ant matchers:
- antMatchers("/unprotected") matches only the exact
/unprotected
URL - mvcMatchers("/unprotected") matches
/unprotected
as well as/unprotected/
,/unprotected.html
,/unprotected.xyz
Also note that if the JWT filter knows there is no permit-all (while entur.authorization.enabled=true
), it will reject
requests without JWT earlier in the chain.
Implementation note: The fully authenticated check will take effect before request unmarshalling and thus before
interaction with closed REST controller endpoints. So unwanted requests (i.e. requests without a token which obviously
will fail @PreAuthorize
authorization checks in the controller) are rejected as early as possible, in a secure and
lightweight way.
To expose actuator endpoints , add
entur:
authorization:
permit-all:
matcher:
patterns:
- /actuator/**
Secure endpoints using method access-control expressions by adding the @PreAuthorize
and @PostAuthorize
annotations. See the following code example to see a basic implementation.
@RestController
public class TestController {
@GetMapping(value = "/myAdminService", produces = arrayOf(MediaType.TEXT_PLAIN_VALUE))
@PreAuthorize("hasAnyAuthority('admin')")
public String authenticatedEndpoint(){
// your code here
}
}
See the Spring documentation for further details.
The library also makes it possible to inject a derived method argument generated from the authorization header:
@RestController
public class TestController {
@GetMapping(value = "/myAdminService", produces = arrayOf(MediaType.TEXT_PLAIN_VALUE))
@PreAuthorize("hasAnyAuthority('admin')")
public String authenticatedEndpoint(JwtPayload token){
// your code here
}
}
See configuration of JwtArgumentResolver
for further details. Alternatively get to the token via the security context:
JwtAuthenticationToken authentication = (JwtAuthenticationToken)SecurityContextHolder.getContext().getAuthentication();
// get token value (including Bearer)
String token = authentication.getCredentials(); // 'Bearer XYZ'
// get claim
String myClaim = authentication.getClaim("myClaim", String.class);
Note that 'storing a lot of stuff' in the access-token is generally not recommended.
It is higly recommended that you have some simple tests that validates that the security configuration is working as expected.
The jwt-junit5-spring module provide JUnit5 support. In a nutshell:
@AuthorizationServer
public class MyTest {
@Test
public void test(@AccessToken String token) throws IOException {
// set token in Autorization header and invoke calls
}
}
The @AuthorizationServer
test extention spawns/overwrites the tenant JWK location with a local file URL.
Use tenant id for multi-tentant support:
@AuthorizationServer("myKeycloak")
@AuthorizationServer("myAuth0")
public class MyTest {
@Test
public void test(@AccessToken(by = "myKeycloak") String token) throws IOException {
// set token in Autorization header and invoke calls
}
}
See jwt-test for further details.
As a reminder; JWKs are the keys used to validate JWT signatures, so the data inside can be trusted. They are provided by the Authorization Server.
The default configuration is as follows:
- refresh cache every 60 minutes
- block at most 15 seconds for cache refresh before failing
- instantly retry once to capture transient IOExceptions
- keep cache at most 10 hours, should cache refresh fail (i.e. outage)
- limit refreshes (triggered by unknown signature keys) to at most 1 per 10 seconds
- Spring HealthIndicator enabled
This corresponds to the following configuration:
entur:
jwt:
jwk:
cache:
enabled: true
time-to-live: 3600 # seconds
refresh-timeout: 15 # seconds
preemptive:
time-to-expires: 15 # seconds
retry:
enabled: true
outage-cache:
enabled: true
time-to-live: 36000 #seconds
rate-limit: # per tenant
enabled: true
refill-rate: 0.1 # per second
Note that eager cache refresh (if enabled) does not kick off until first time the cache is populated, which would normally be through health checks or first time a token is to be validated.
The authorization server might choose to rotate its keys. When that happens, the key id used to sign the access-tokens changes, and a new key id will be noted in the JWT header. This implementation will promptly refresh the keys and normally be able to instantly verify new tokens. Previously issued tokens will be able to verify if the key list still contains the old key.
The library supports a Spring HealthIndicator via the enabling jwks
health indicator. It looks at the last attempt to get signing keys. It will trigger a refresh if
- no previous attempt was made
- last attempt was unsuccessful
In other words, the health check will not refresh expired keys, but repeated calls to the health-check will result in a positive result once downstream services are back up. As a positive side-effect, on startup, calling the health-check before opening for traffic will result in the cache being populated (read: warmed up).
For copying interesting JWT fields through to the MDC logging context, configure mappings:
entur:
jwt:
mdc:
enabled: true
mappings:
- from: iss #from claim
to: issuer
The CORS support is intended for use-cases where your customers do NOT normally call your API directly from web-browser and other distributed apps like mobile applications (technically, this cannot be prevented using CORS). With the notable exception of your development portal.
This is so that API-keys, client credentials, access-tokens and/or other secrets are not compromised. Webapps are intended to have a dedicated backend service on the same domain, so no CORS request are necessary.
Configuration example:
entur:
cors:
enabled: true
mode: xyz
origins:
- https://myapp.entur.org
- https://myotherpetstore.swagger.io
where xyz
is from the following list
api
- CORS requests from selected sites. Recommended exceptions:- Your API development portal
- Petstore
webapp
- no CORS requests are accepted
If no mode is set, no configuration is added by this starter. This allows for adding your own custom implementation.
@Bean("corsConfigurationSource")
public CorsConfigurationSource myCorsConfigurationSource() {
// ...
}
Note that the bean name must be as above in order for Spring to pick up the bean.
In general, the API gateway should respond with HTTP 403 to requests with unknown origins. All other requests, including OPTIONS calls, can be sent backwards to the Spring application.
See jwt-spring-auth0-web for a concrete implementation example.