Installed Auth Adapter (csw-aas-installed)

csw-aas-installed is the adapter you will use if you want to build a client application that executes on a user’s machine and talks to an AAS-protected web service application, such as a CLI application. The Configuration Service Admin API makes use of this library.

This is as opposed to building a web application that runs in a browser. To do that, use the csw-aas-http library.

Dependencies

To use the Akka HTTP Adapter (csw-aas-installed), add the following to your build.sbt file:

sbt
libraryDependencies += "com.github.tmtsoftware.csw" %% "csw-aas-installed" % "5.0.1"

Prerequisites

To run a client app with AAS access, we need

  • The CSW Location Service running
  • An AAS instance running and registered with the Location Service
  • A protected HTTP server running

All of these can be running on different machines. To start a Location Service and AAS server on a local machine, you can make use of the csw-services application.

Application Configurations

All AAS related configurations go inside an auth-config block in your application.conf file. There are two configurations applicable for a public client application: realm and client-id.

realm has a default value of TMT if not specified. Normally, all apps in TMT should not have to override this, however it might be useful to override this while testing your app.

client-id is a mandatory configuration which specifies the client ID of the app as per its registration in AAS.

disabled is an optional configuration with default value of false. This flag can be turned on for local development and testing purposes. It can greatly ease the process of testing business logic without having to go through the process of creating users, managing roles and logging in with user credentials to generate valid access tokens. For it to work, the same flag needs to turned on in the server application too.

Caution

This should not be used in production

auth-config {
  realm = TMT # DEFAULT
  client-id = demo-cli # REQUIRED
  disabled = false # DEFAULT
}

Building a CLI Application

Let’s say that we have an existing Akka HTTP application which has some open and some protected routes, and we want to build a CLI client which accesses these routes.

Scala
sourcevar data: Set[String] = Set.empty

val routes: Route =
  pathPrefix("data") {
    get {                    // un-protected route for reading data
      pathEndOrSingleSlash { // e.g HTTP GET http://localhost:7000/data
        complete(data)
      }
    } ~ sPost(RealmRolePolicy("admin")) { // only users with 'admin' role is allowed for this route
      parameter("value") { value =>       // e.g POST GET localhost:7000/data?value=abc
        data = data + value
        complete(StatusCodes.OK)
      }
    }
  }
Note

To know more about how to create secure web APIs, please go through Akka HTTP Adapter - csw-aas-http

We will create a CLI application that has following commands:

command description
login performs user authentication
logout logs user out
read reads data from server
write {content} writes data to server

Let’s begin with Main.scala

Scala
sourceobject Main extends App {

  LocationServerStatus.requireUpLocally()

  implicit val actorSystem: ActorSystem[_] = ActorSystem(Behaviors.empty, "example-system")

  val adapter: InstalledAppAuthAdapter = AdapterFactory.makeAdapter

  val command: Option[AppCommand] = CommandFactory.makeCommand(adapter, args)

  try {
    command.foreach(_.run())
  }
  finally {
    actorSystem.terminate()
  }
}

The statement LocationServerStatus.requireUpLocally() ensures that the Location Service is up and running before proceeding further. If it is not running, an exception will be thrown and the application will exit.

Note

In a real application, you would ideally want to use LocationServerStatus.requireUp which takes locationHost: String parameter instead of looking for the Location Service on the localhost.

Next, we will instantiate InstalledAppAuthAdapter. There is a factory already available to create the required instance. We will create a small factory on top of this factory to keep our Main.scala clean.

Scala
sourceobject AdapterFactory {
  def makeAdapter(implicit actorSystem: typed.ActorSystem[_]): InstalledAppAuthAdapter = {
    implicit val ec: ExecutionContextExecutor = actorSystem.executionContext
    val locationService: LocationService      = HttpLocationServiceFactory.makeLocalClient(actorSystem)
    val authStore                             = new FileAuthStore(Paths.get("/tmp/demo-cli/auth"))
    InstalledAppAuthAdapterFactory.make(locationService, authStore)
  }
}

Note the the internal factory method we have used requires two parameters: a reference to the Location Service to resolve the AAS Server, and authStore, which is a file-based access token storage system.

Warning

In this case we have configured it to store all tokens in the “/tmp/demo-cli/auth” directory, but ideally you want this location to be somewhere in the user’s home directory. This will ensure that different users don’t have access to each other’s tokens.

Coming back to Main.scala, now we need to find out which command the user wants to execute. To parse the user input arguments, we will create a small utility.

Scala
sourceobject CommandFactory {
  def makeCommand(adapter: InstalledAppAuthAdapter, args: Array[String])(implicit
      actorSystem: typed.ActorSystem[_]
  ): Option[AppCommand] = {

    // ============ NOTE ============
    // We are doing hand parsing of command line arguments here for the demonstration purpose to keep things simple.
    // However, we strongly recommend that you use one of the existing CLI libraries. CSW makes extensive use of scopt.
    args match {
      case Array("login")          => Some(new LoginCommand(adapter))
      case Array("logout")         => Some(new LogoutCommand(adapter))
      case Array("read")           => Some(new ReadCommand)
      case Array("write", content) => Some(new WriteCommand(adapter, content))
      case _ =>
        println("invalid or no command\nvalid commands are: login, logout, read & write")
        None
    }
  }
}

All of these commands extend from a simple trait - AppCommand.

Scala
sourcetrait AppCommand {
  def run(): Unit
}

Its single method run is executed in our application once the arguments are parsed into an AppCommand.

Note

We could have used a command line parser library here to parse the command names and options/arguments, but since our requirements are simple and this is a demonstration, we will keep things simple. However, we strongly recommend that you use one of the existing libraries. CSW makes extensive use of scopt. There are other libraries which are equally good and easy to use.

Let’s go through each command one by one:

Login

Scala
sourceclass LoginCommand(val installedAppAuthAdapter: InstalledAppAuthAdapter) extends AppCommand {
  override def run(): Unit = {
    installedAppAuthAdapter.login()
    println("SUCCESS : Logged in successfully")
  }
}

Here the constructor takes an InstalledAppAuthAdapter as a parameter, and in the run method, it calls installedAppAuthAdapter.login(). This method opens a browser and redirects the user to a TMT login screen (served by AAS). In the background, it starts an HTTP server on a random port. Once the user submits the correct credentials on the login screen, AAS redirects the user to http://localhost:[SomePort] with the access and refresh tokens in a query string. The InstalledAppAuthAdapter will then save these tokens to the file system using FileAuthStore. After this, InstalledAppAuthAdapter will shut down the local server since it is no longer needed. The user can then close the browser.

If you want to develop a CLI app that is not dependent on a browser, you can call loginCommandLine() method instead of login(). This will prompt the user to provide credentials in the CLI instead of opening a browser.

Note

While the loginCommandLine() method is available, a browser is generally more user-friendly since it can store cookies and remember passwords.

Logout

Scala
sourceclass LogoutCommand(val installedAppAuthAdapter: InstalledAppAuthAdapter) extends AppCommand {
  override def run(): Unit = {
    installedAppAuthAdapter.logout()
    println("SUCCESS : Logged out successfully")
  }
}

The structure here is very similar to the login command. installedAppAuthAdapter.logout() clears all the tokens from the file system via FileAuthStore.

Read

Scala
sourceclass ReadCommand(implicit val actorSystem: typed.ActorSystem[_]) extends AppCommand {
  implicit lazy val ec = actorSystem.executionContext
  override def run(): Unit = {
    val url = "http://localhost:7000/data"
    Http()
      .singleRequest(HttpRequest(uri = Uri(url)))
      .map(response => {
        convertToString(response.entity).map(println)
      })
  }
}

Since the get route is not protected by any authentication or authorization in the our example server, the read command simply sends a get request and prints the response.

Write

Scala
sourceclass WriteCommand(val installedAppAuthAdapter: InstalledAppAuthAdapter, value: String)(implicit
    val actorSystem: typed.ActorSystem[?]
) extends AppCommand {
  override def run(): Unit = {

    installedAppAuthAdapter.getAccessToken() match {
      case Some(token) =>
        val bearerToken = headers.OAuth2BearerToken(token.value)
        val url         = s"http://localhost:7000/data?value=$value"
        Http()
          .singleRequest(
            HttpRequest(
              method = HttpMethods.POST,
              uri = Uri(url),
              headers = List(headers.Authorization(bearerToken))
            )
          )
          .map(response => {
            response.status match {
              case OK           => println("Success")
              case Unauthorized => println("Authentication failed")
              case Forbidden    => println("Permission denied")
              case code         => println(s"Unrecognised error: http status code = ${code.value}")
            }
          })(actorSystem.executionContext)

      case None =>
        println("you need to login before executing this command")
        System.exit(1)
    }
  }
}

The WriteCommand constructor takes an InstalledAppAuthAdapter and a string value, passed in at the command line. Since the post route is protected by a realm role policy in our example server, we need to pass a bearer token in the request header.

installedAppAuthAdapter.getAccessTokenString() checks the FileAuthStore and returns an Option[String]. If the Option is None, it means that user has not logged in and an error message is displayed. If the token is found, the bearer token is obtained and passed in the header of the request to the HTTP server. The HTTP server uses this token to determine whether the client has the proper permissions to perform the request.

If the response status code is 200, it means authentication and authorization were successful. In our example, authorization required that the user had the admin role.

If the response is 401 (StatusCodes.Unauthorized), there is something wrong with the token. It could indicate that token has expired or does not have a valid signature. InstalledAppAuthAdapter ensures that you don’t send a request with an expired token. If the access token is expired, it refreshes the access token with the help of a refresh token. If the refresh token has also expired, it returns None which means that user has to log in again.

If the response is 403 (StatusCodes.Forbidden), the token is valid but the token is not authorized to perform that action. In our example, this would occur if the user does not have the admin role.

Source code for above examples

Example