Integrating Open Policy Agent with Spring Security Reactive and JSON Web Tokens

We present a Java library that simplifies adopting the Open Policy Agent server to manage user authorization for a Spring Boot microservice, while also managing API Token (JWT) authentication.

Motivation

Spring Security assumes a fairly simplistic Role-Based access control (RBAC) where the service authenticates the user (via some credentials, typically username/password) and returns a UserDetails object which also lists the Authorities that the Principal has been granted.

While it is also possible to integrate Spring Security with JSON Web Tokens (JWT) this is also rather cumbersome, and lacks flexibility.

Finally, integrating the app with an Open Policy Agent server with the relatively new Spring Reactive (WebFlux) model is far from straightforward.

Ultimately, however, Spring Security “collapses” authentication and authorization into a single process, based on the UserDetails abstraction, which sometimes does not allow sufficient flexibility.

This library allows a Web application to:

  • clearly separate authentication from authorization;
  • easily adopt JWTs (API Tokens) as a means of authentication;
  • simplify integration with OPA for authorization;
  • keeping the authorization logic (embedded in Rego policies) separate from the business logic (carried out by the application).

It also provides a blueprint to inject OPA authorization in a Spring Reactive (WebFlux) application.

All the code described below can be found at: https://github.com/massenz/jwt-opa, and is released under the Apache 2 open source license.

Architecture

To acquire an API Token the client needs to access one of the “authenticated” endpoints (as defined in the routes.authenticated list property – see the RoutesConfiguration class) and obtain a valid JWT from the JwtTokenProvider; an example of how to do this (using a simple Spring Data repository, backed by MongoDB) is in the /login controller in the example app (LoginController): the SecurityConfiguration class is what one would implement in any Spring Application with Spring Security enabled:

@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {
  @Bean
  public ReactiveUserDetailsService userDetailsService(ReactiveUsersRepository repository) {
    return username -> {
      return repository.findByUsername(username)
          .map(User::toUserDetails);
    };
  }
}

Obviously, instead of accessing a local database, the application could use a WebClient to access a remote service to retrieve any details (including an encoded password).

Once the user has been authenticated, we can generate a JWT API Token, and return it to the client:

@GetMapping
Mono<JwtController.ApiToken> login(
    @RequestHeader("Authorization") String credentials
) {
  return usernameFromHeader(credentials)
      .flatMap(repository::findByUsername) // See Note.
      .map(u -> {
        String token = provider.createToken(u.getUsername(), u.roles());
        return new JwtController.ApiToken(u.getUsername(), u.roles(), token);
      })
      .doOnSuccess(apiToken ->
          log.debug("User {} authenticated, API Token generated: {}",
              apiToken.getUsername(), apiToken.getApiToken()));
}

NoteAs you may notice, we are duplicating the round-trip to the DB for the User data; this may (or may not) be a performance issue, especially on performance-sensitive APIs: an obvious solution would be to use either a co-located cache, or even an in-memory one, with a relatively short TTL.

Authorization via Open Policy Agent server

Image by Gerd Altmann from Pixabay

Once the client has an API Token, it can be used to authorize any other request: this is done via the OpaReactiveAuthorizationManager as a ReactiveAuthorizationManager, chained after the JwtReactiveAuthorizationManager which takes care of validating the API Token.

All of this is done transparently by the jwt-opa library, without having to change anything in the actual application.

@Override
public Mono<AuthorizationDecision> check(
    Mono<Authentication> authentication,
    ServerHttpRequest request
) {

  return authentication.map(auth -> {
        return makeRequestBody(auth.getCredentials().toString(), request);
      })
      .flatMap(body -> client.post()
          .accept(MediaType.APPLICATION_JSON)
          .contentType(MediaType.APPLICATION_JSON)
          .bodyValue(body)
          .exchange())
      .flatMap(response -> response.bodyToMono(Map.class))
      .map(res -> {
        Object result = res.get("result");
        if (StringUtils.isEmpty(result)) {
          return Mono.error(unauthorized());
        }
        return result.toString();
      })
      .map(o -> Boolean.parseBoolean(o.toString()))
      .map(AuthorizationDecision::new);
}

Simplified code excerpt, please see the OpaReactiveAuthorizationManager class for the full code

the client is a Spring WebClient configured to connect to the OPA Server as configured via the OpaServerConfiguration configuration, which reads the following properties from application.yaml:

opa:
  server: "localhost:8181"
  policy: kapsules
  rule: allow

This will eventually send a TokenBasedAuthorizationRequestBody (encoded as JSON) to the following endpoint:

http(s)://localhost:8181/v1/data/kapsules/allow

The allow rule will eventually grant/deny access to the requested endpoint (given the HTTP Method and, optionally, the request’s body content).

See OPA Policies below, and the OPA Documentation for more details on Rego and the server REST API.

Authorizing Requests

As shown above, the check() method sends a JSON body to the OPA server, generated by:

makeRequestBody(auth.getCredentials().toString(), request);

this is a TokenBasedAuthorizationRequestBody.RequestBody object which carries the path and HTTP method of the HTTP request, alongside the JWT, which can then be used by the OPA policy to evaluate the grant:

public class TokenBasedAuthorizationRequestBody {

   // The OPA server requires every POST body to the Data API to be wrapped inside
   // an "input"} object, we use this class to simplify the construction
   // of the JSON body.
  public static class RequestBody {
    TokenBasedAuthorizationRequestBody input;
  }

  public static class Resource {
    String path;
    String method;
  }

  @JsonProperty("api_token")
  String token;
  Resource resource;
}

maps to something like:

{
  "input": {
    "resource": {
      "path": "/users",
      "method":"GET"
    },
    "api_token":"eyJ0eXAiOi...PCHQ"
  }
}

The api_token, once decoded inside the Rego policy (using the io.jwt.decode() helper method), carries the following fields:

"token": [
    {
        "alg": "ES256",
        "typ": "JWT"
    },
    {
        "iss": "demo-issuer",
        "roles": [
            "SYSTEM"
        ],
        "sub": "admin"
    },
    "e8b8bb5d5....37bf2a63c21d"
]

The user (or Principal or Subject) is in the "sub" field, and the Issuer ("iss") is configured to be the authority generating the Token (see JWT Handbook for more details): the Token is decoded as an array, containing, in order, the header, body and signature.

In client applications using the jwt-opa library, tokens can be generated using the JwtTokenProvider class.

Signing Keys

To sign the JWT we use Elliptic Cryptography3; this a family of asymmetric encryption algorithms which require a private/public key pair.

Briefly, an “elliptic cryptography” key pair can be generated with:

  1. generate the EC param openssl ecparam -name prime256v1 -genkey -noout -out ec-key.pem
  2. generate EC private key openssl pkcs8 -topk8 -inform pem -in ec-key.pem -outform pem \ -nocrypt -out ec-key-1.pem
  3. generate EC public key openssl ec -in ec-key-1.pem -pubout -out public.pem

In the repository there is a utility keygen.sh script that will do this for you.

Save both keys in a private folder (not under source control) and then point the relevant application configuration (application.yaml) to them:

secrets:
  keypair:
    private: "private/ec-key-1.pem"
    pub: "private/ec-key-pub.pem"

3See the excellent “JWT Handbook” by Auth0 for a primer on EC.

Web Server (Demo app)

This is a very simple Spring Boot application, to demonstrate how to integrate the jwt-opa library; there is still some work to refine it, but by and large, it gives a good sense of what is required to integrate a Spring Reactive app with an OPA server:

  1. implement a SecurityConfiguration @Configuration class;
  2. implement a mechanism to retrieve UserDetails given a username; and
  3. implement something similar to the LoginController to serve API Tokens to authenticated users.

In future releases of the jwt-opa library we may also provide “default” implementations of some or all of the above, if this can be done without limiting too much client’s options; or maybe they could be provided in a jwt-opa-starter extension library.

Trying out the demo

Supporting Services

The sample app (webapp-example) requires the following services:

  • Mongo (to store users); and
  • OPA Policy Server.

Use the following to run the servers locally:

docker run --rm -d -p 27017:27017 --name mongodb mongo:4
docker run --rm -d -p 8181:8181 --name opa openpolicyagent/opa:0.25 run --server

See Docker Hub – OPA image for details and the most recent stable version to use.

After starting the server (./gradlew bootRun), you will see in the log the generated password
for the admin user:

2021-02-04 23:23:17 INFO 90860 ... Initializing DB with seed user (admin). Use the generated password: 342dfa7b-4

Note

The system user does not get re-created, if it already exists: if you lose the random password, you will need to manually delete it from Mongo directly:

docker exec -it mongo mongo
> show dbs;
...
opa-demo-db  0.000GB
> use opa-demo-db
> db.users.find()
{ "_id" : ObjectId("5ff8173b20953c451f10a384"), "username" : "admin", ...
> db.users.remove(ObjectId("5ff81..."))

and then restart the server to recreate the admin user.
Alternatively, just stop & restart the Mongo container (but all data will be lost).

To access the /login endpoint, you will need to use Basic authentication:

$ http :8080/login --auth admin:342dfa7b-4

this will generate a new API Token, that can then be used in subsequent HTTP API calls, with the Authorization header:

$ http :8080/users Authorization:"Bearer ... JWT goes here ..."

(see the HTTPie project documentation to learn how to use the http command; or just use curl).

OPA Policies

See the OPA documentation and in particular the Rego Playground and REST API for more details on how to implement fine-grained authorization policies using the OPA Server.

Some examples are stored in src/main/rego and can be uploaded to the OPA policy server via a POST request, with the policy request in the body itself (text/plain encoded); for example, the following extracts the roles from the JWT and then allows any incoming request if the user has a SYSTEM role:

default allow = false

token := t {
    t := io.jwt.decode(input.api_token)
}

user := u {
    some i
    u = token[i].sub
}

roles := r {
    some i
    r = token[i].roles
}

# System administrators can modify all entities
is_sysadmin {
    some i
    roles[i] == "SYSTEM"
}

# System accounts are allowed to make all API calls.
allow {
    is_sysadmin
}

Examples of policy evaluations are in src/test/policies_tests as JSON files; they can be executed against the policy server using the /data endpoint:

POST http://localhost:8181/v1/data/kapsules/valid_token

{
  "input": {
      "user": "myuser",
      "role": "USER",
      "token": "eyJ0eXAi....iCzY"
  }
}

The OPA Policy Reference has more examples and details on built-in commands and utilities.

Complete code

The full code for both the jwt-opa library and the example webapp can be found on Github at: https://github.com/massenz/jwt-opa.

Feel free to submit ideas and suggestion via GitHub Issues, and contribute to the code via Pull Requests.


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s