fullfacing / keycloak4s

Keycloak4s is a Scala client for the Keycloak Admin API
MIT License
66 stars 14 forks source link
admin async authn authz client keycloak rest scala sttp

keycloak4s

CircleCI codecov Maven Central Scala Steward badge

A Scala-based middleware API for Keycloak
Supports version 19.0.1

keycloak4s is an opinionated Scala-built API that serves as a bridge between any Scala project and a Keycloak server. It allows access to the server's Admin API, and provides adapters that validates Keycloak's bearer tokens. It authorizes requests via a JSON config file inspired by their policy enforcement configuration.

The project is split into the following modules, each as a separate dependency:

Contents

  1. Installation
  2. Module: keycloak4s-core
  3. Module: keycloak4s-admin
  4. Module: keycloak4s-admin-monix
  5. Module: keycloak4s-auth-akka-http
    1. Token Validation
    2. Policy Enforcement Configuration
    3. Plugging in the Adapter
    4. Payload Extraction
  6. Logging
  7. Error Messages

Installation

Each module can be pulled into a project separately using the following SBT dependencies:

Cats 3 modules

The core module is a dependency for all other modules, and is automatically pulled in when using any other module.

Module: keycloak4s-core

The core module shares common functionality (such as logging and error handling) and data models with its dependent modules.

It is important to note that the KeycloakConfig model contains the Keycloak server information. This information will be required often. Included in this set of information, is the following:

Example:

import com.fullfacing.keycloak4s.core.models.KeycloakConfig
import com.fullfacing.keycloak4s.core.models.ConfigWithAuth

val authConfig = KeycloakConfig.Secret(
    realm         = "master",
    clientId      = "admin-cli",
    clientSecret  = "b753f3ba-c4e7-4f3f-ac16-a074d4d89353"
)

val keycloakConfig = ConfigWithAuth(
    scheme  = "http",
    host    = "fullfacing.com/keycloak",
    port    = 8080,
    realm   = "demo",
    authn   = authConfig,
    basePath = List("auth")
)

Module: keycloak4s-admin

The module uses the client credential flow behind the scenes to simplify access to Keycloak's Admin API. In order to make calls to it, a client needs to be created with the correct server details and credentials to connect to the Keycloak server, followed by a service handler that can invoke the calls. The process can be broken down into the following steps:

1 - Create a KeycloakConfig
Refer to the keycloak4s-core segment for details on KeycloakConfig. Please note that the authorization realm must be a service account-enabled admin client.

2 - Create a KeycloakClient
The KeycloakClient handles the HTTP calls to the KeycloakServer. It requires a KeycloakConfig, and an sttp backend (the KeycloakClient and the sttp backend must match parametric types). Alternatively, the Akka/Monix module can be used for a concrete implementation. For more information, refer to the Module: keycloak4s-admin-monix module of this document.

Example:

implicit val backend: SttpBackend[Task, Observable[ByteBuffer]] = AsyncHttpClientBackend()
implicit val keycloakClient: KeycloakClient[Task, Observable[ByteBuffer]] = new KeycloakClient[Task, Observable[ByteBuffer]](config)

3 - Create a service handler
In this context, a service handler contains the admin calls that are relevant to a specific aspect of Keycloak. For example, the Users service contains calls that creates, retrieves and manipulates Keycloak Users. Please note: When creating a service handler, an implicit KeycloakClient must be in scope.

Example:

val usersService = Keycloak.Users[Task, Observable[ByteBuffer]]
val rolesService = Keycloak.Roles[Task, Observable[ByteBuffer]]

4 - Invoke the calls
The relevant admin API calls can be invoked from a service handler. This will automatically handle calls for access, and refresh tokens in the background through the client credentials flow.

Example:

val newUser = User.Create(username = "ff_keycloak_user_01", enabled = true)
val newGroup = Group.Create(name = "ff_keycloak_group_01")

for {
  u <- EitherT(usersService.createAndRetrieve(newUser))
  g <- EitherT(groupsService.createAndRetrieve(newGroup))
  _ <- EitherT(usersService.addToGroup(u.id, g.id))
} yield (u, g)

The majority of the functions in a service handler corresponds directly with an admin API route. However, a few are composite functions created for convenience. An example of this can be seen in the aforementioned example where thecreateAndRetrieve function chains a create- and a fetch call.

Module: keycloak4s-admin-monix

keycloak4s-admin-monix can be used as an alternative to keycloak4s-admin. This module is typed to Monix with Tasks as the response wrapper and Observables as the streaming type. This removes the need to set up the types for KeycloakClient or the service handlers, apart from the type of byte collection used by the backend for streaming. Additionally, this module contains reactive streaming variants of the fetch calls, which allows for batch retrieval and processing.

The steps to make these calls remains the same as in the keycloak4s-admin. The following example provides the specific pre-built sttp backend. For more information, refer to Module: keycloak4s-admin in this document.

Example:

implicit val backend: SttpBackend[Task, Observable[ByteString]] = AkkaMonixHttpBackend()
implicit val monixClient: KeycloakClient[ByteString] = new KeycloakClient(...) // truncated, see keycloak4s-core segment for details

val usersService = Keycloak.Users[ByteString]

usersService.fetch()

To use the streaming variants of this module simply replace any fetch call with fetchS, which takes an additional batchSize parameter. The function returns an Observable and operates in batches by calling to retrieve the specified batch size, processing the batched results, then making the call for the next batch.

Example:

val usersService = Keycloak.Users

// return the IDs of all disabled Users
usersService.fetchS(batchSize = 20)
  .dropWhile(!_.enabled)
  .map(_.id)
  .toListL

A fetchL variant is also available which performs the same batch streaming, but automatically converts the Observable to a List of Task when the stream has completed.

Module: keycloak4s-auth-akka-http

Please note: This module is especially opinionated and was designed with our company's needs in mind. However, an effort was made to keep it as abstract as possible to allow for repurposed use. Feedback on its usability is encouraged.

This module is a client adapter for Akka-HTTP that allows the service to validate Keycloak's bearer tokens (through the use of Nimbus JOSE + JWT). It provides high-level RBAC authorization for requests via Akka-HTTP's directives, and a JSON policy enforcement configuration.

Token Validation
With the adapter plugged in, all requests are expected to contain an authorization header with a bearer token. Additionally, an ID token can be passed along with the header Id-Token.

The tokens are first parsed, and then validated for the following:

To validate tokens the adapter requires an implicit TokenValidator in scope, and to create an instance of the validator the adapter requires a JSON Web Key set (JWKS) as well as a KeycloakConfig. This adapter provides two different methods of constructing a TokenValidator:

Example:

val keycloakConfig = KeycloakConfig(...) // truncated, see core segment for details

// creating a static TokenValidator

val file = new File("/keycloak4s/jwks.json")
val jwks = JWKSet.load(file)

implicit val staticValidator: TokenValidator = TokenValidator.Static(jwks, keycloakConfig)

// creating a dynamic TokenValidator

implicit val dynamicValidator: TokenValidator = TokenValidator.Dynamic(keycloakConfig)

Alternatively, a TokenValidator with custom JWKS handling can be created. To do so requires writing a concrete implementation of the JwksCache trait, which contains the functionality of how the JWKS is handled. The concrete trait then simply needs to be mixed into the abstract TokenValidator class.

Example:

class CustomJwksCache extends JwksCache {
  // concrete implementations of the JwksCache abstract functions
}

class CustomValidator(config: KeycloakConfig)(implicit ec: ExecutionContext = global)
  extends TokenValidator(config) with CustomJwksCache

val keycloakConfig = KeycloakConfig(...) // truncated, see keycloak4s-core segment for details

implicit val customValidator: TokenValidator = new CustomValidator(keycloakConfig)

Policy Enforcement Configuration
Request authorization in keycloak4s is performed by evaluating requests against a set of policy-enforcement rules. The service's rules are represented by a policy configuration object. This object is parsed from a JSON structure, inspired by Keycloak's policy enforcement JSON configuration. Incoming requests are compared to these rules in order to determine which permissions a bearer token needs to contain.

The configuration JSON file must be placed in a project's resources folder. Specifying the file name allows for the construction of the policy enforcement object:
val policyConfig = PolicyBuilders.buildPathAuthorization("config_name.json").

In our intended use case, the clients of a Keycloak realm each represent a specific API. Client-level roles are then created for each client as available permissions for the API, after which the roles can be assigned to users, granting permissions as required.

The following is an example of an access token payload for a user that is authorized with admin access to one API, and read/write access for a particular resource on another:

{
  "resource_access": {
    "api-one": {
      "roles": [
        "admin"
      ]
    },
    "api-two": {
      "roles": [
        "read-resource1", "write-resource1"
      ]
    }
  },
  "iss": "https://com.fullfacing:8080/auth",
  "exp": 1562755799,
  "iat": 1562755739
}

Example of the Policy Configuration JSON Structure:

{
  "service" : "api-one",
  "enforcementMode" : "ENFORCING",
  "paths" : [
    {
      "path" : "/v1/resource1/{id}/another-resource/{id}/action",
      "methodRoles": [
        {
          "method" : "*",
          "roles" : "action-admin"
        },
        {
          "method" : "POST",
          "roles" : [ "action-post", "action-admin" ]
        }
      ]
    },
    {
      "path" : "/v1/*",
      "methodRoles" : [
        {
          "method" : "*",
          "roles" : "admin"
        }
      ]
    }
  ]
}

Scala Representation:

sealed trait RequiredRoles

final case class And(and: List[Either[RequiredRoles, String]]) extends RequiredRoles
final case class Or(or: List[Either[RequiredRoles, String]])  extends RequiredRoles

JSON Representation:

{
  "roles" : {
    "and" : [
      {
        "or" : [ "resource-read", "resource-write", "resource-delete" ]
      },
      {
        "or" : [ "resource2-read", "resource2-write", "resource2-delete" ]
      },
      {
        "or" : [ "segment-read", "segment-write", "segment-delete" ]
      }
    ]
  }
}

Note: Should an incoming request have multiple, unique rules that apply to it (for example, a rule with both a wildcard segment/method and a concrete rule), the request is evaluated using both rules, and will be accepted if either succeeds.

Plugging in the Adapter
In order for the adapter to validate and authorize requests, it needs to be plugged into the Akka-HTTP routes, for which there are two requirements:

Once the validator and configuration is ready, the adapter can be plugged in:

  1. Mix the SecurityDirectives trait into the class containing the routes. This provides the secure directive which plugs the adapter's functionality in.
  2. Invoke secure with the policy enforcement configuration, and wrap the entire Akka-HTTP Route structure inside the directive.

Example:

object AkkaHttpRoutes extends SecurityDirectives {
  val enforcementConfig: PathAuthorisation = PolicyEnforcement.buildPathAuthorisation("enforcement.json")

  val keycloakConfig = KeycloakConfig(...) // truncated, see core segment for details
  implicit val validator: TokenValidator = TokenValidator.Dynamic(keycloakConfig)

  secure(enforcementConfig) { payloads =>
    path("api" / "v2") {
      // akka-http route structure
    }
  }
}

Token Payload Extractors
After validation, the secure directive provides the payloads of the bearer tokens for further information extraction or processing. The payloads are in a JSON structure native to Nimbus JOSE + JWT. However, to simplify extraction, this module includes implicits with safe extraction functionality.

To gain access to the extractors, the implicits need to be in scope. After it has been defined to be in scope, a set of generic extractors can be used on any Nimbus Payload object.

Example:

import com.fullfacing.keycloak4s.auth.core.PayloadImplicits._

val payload: Payload = ... // truncated

// extracts the value for a given key as a String
val tokenType: Option[String] = payload.extract("typ")

// extracts the value for a given key as the given type
val sessionState: Option[UUID] = payload.extractAs[UUID]("session_state")

// extracts the value for a given key as a List of Strings
val audiences: List[String] = payload.extractList("aud")

// extracts the value for a given key as a List of the given type
val allowedIds: List[UUID] = payload.extractAsListOf[UUID]("allowed_ids")

The parametric extractors use the internal json4s serializers by default, but can be customized by passing a json4s Formats instance explicitly to the function, or by declaring it implicitly in scope.

Alongside the generic extractors are additional extractors for commonly required values; such as extractScopes, extractEmail, etc.

Logging

keycloak4s has customized logging spanning over trace, debug and error levels using SLF4J. To restrict logging output, the following Logger names should be referenced:

Internal correlation UUIDs are passed between function calls of the same request to assist in tracing logs and debugging. Normally, a correlation ID is generated for each request. However, a UUID can be passed along for a request if there is a need for it. To do so requires passing the UUID and policy enforcement configuration as a Tuple into the secure directive (refer to Plugging in the Adapter).

Example:

// an example function to extract a UUID from a request sent through Postman
def contextFromPostman: Directive1[UUID] = {
  optionalHeaderValueByName("Postman-Token").flatMap { cId =>
    provide {
      cId.fold(UUID.randomUUID())(UUID.fromString)
    }
  }
}

contextFromPostman { cId =>
  secure((enforcementConfig, cId)) { payloads =>
    path("api" / "v2") {
      // akka-http route structure
    }
  }
}

Error Messages

When an exception inside keycloak4s is captured instead of thrown, it is converted into a KeycloakError subtype, depending on the cause or location of the error. KeycloakError extends Throwable and can thus still be thrown or processed as one.

The subtypes of KeycloakError are as follows: