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.
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
-
source
var 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) } } }
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
-
source
object 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.
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
-
source
object 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.
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
-
source
object 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
-
source
trait AppCommand { def run(): Unit }
Its single method run
is executed in our application once the arguments are parsed into an AppCommand
.
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
-
source
class 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.
While the loginCommandLine()
method is available, a browser is generally more user-friendly since it can store cookies and remember passwords.
Logout
- Scala
-
source
class 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
-
source
class 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
-
source
class 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.