From 1e5d86938d193c0327c34607dd1835cbb04d7545 Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Sat, 12 Oct 2024 09:46:50 +0100 Subject: [PATCH 1/5] DRTII-1609 Add Keycloak classes --- build.sbt | 3 + .../drt/keycloak/KeyCloakAuth.scala | 132 +++++++++++++++++ .../drt/keycloak/KeyCloakClient.scala | 138 ++++++++++++++++++ .../drt/keycloak/KeyCloakGroups.scala | 52 +++++++ .../homeoffice/drt/keycloak/KeyCloakApi.scala | 12 ++ 5 files changed, 337 insertions(+) create mode 100644 jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuth.scala create mode 100644 jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala create mode 100644 jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakGroups.scala create mode 100644 shared/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakApi.scala diff --git a/build.sbt b/build.sbt index 20159e09..2038a85b 100644 --- a/build.sbt +++ b/build.sbt @@ -20,6 +20,7 @@ lazy val root = project.in(file(".")). ) lazy val akkaVersion = "2.8.5" +lazy val akkaHttpVersion = "10.5.2" lazy val jodaVersion = "2.12.5" lazy val upickleVersion = "3.1.3" lazy val sparkMlLibVersion = "3.5.0" @@ -55,6 +56,8 @@ lazy val cross = crossProject(JVMPlatform, JSPlatform) "com.typesafe.akka" %% "akka-actor" % akkaVersion, "com.typesafe.akka" %% "akka-persistence" % akkaVersion, "com.typesafe.akka" %% "akka-persistence-query" % akkaVersion, + "com.typesafe.akka" %% "akka-http" % akkaHttpVersion, + "com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpVersion, "com.typesafe.akka" %% "akka-slf4j" % akkaVersion, "joda-time" % "joda-time" % jodaVersion, "org.apache.spark" %% "spark-mllib" % sparkMlLibVersion, diff --git a/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuth.scala b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuth.scala new file mode 100644 index 00000000..cdc8e7f6 --- /dev/null +++ b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuth.scala @@ -0,0 +1,132 @@ +package uk.gov.homeoffice.drt.keycloak + +import akka.actor.ActorSystem +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.Accept +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.Materializer +import org.slf4j.{Logger, LoggerFactory} +import spray.json.{DefaultJsonProtocol, JsNumber, JsObject, JsString, JsValue, RootJsonFormat} + +import scala.concurrent.Future + +abstract case class KeyCloakAuth(tokenUrl: String, clientId: String, clientSecret: String, sendHttpRequest: HttpRequest => Future[HttpResponse]) + (implicit val system: ActorSystem, mat: Materializer) + extends KeyCloakAuthTokenParserProtocol { + + import system.dispatcher + + val log: Logger = LoggerFactory.getLogger(getClass) + + def formData(username: String, password: String, clientId: String, clientSecret: String) = FormData(Map( + "username" -> username, + "password" -> password, + "client_id" -> clientId, + "client_secret" -> clientSecret, + "grant_type" -> "password" + )) + + def getToken(username: String, password: String): Future[KeyCloakAuthResponse] = { + val request = HttpRequest( + method = HttpMethods.POST, + uri = Uri(tokenUrl), + headers = List(Accept(MediaTypes.`application/json`)), + entity = formData(username, password, clientId, clientSecret).toEntity) + + val requestWithHeaders = request.addHeader(Accept(MediaTypes.`application/json`)) + + sendHttpRequest(requestWithHeaders).flatMap { r => + Unmarshal(r).to[KeyCloakAuthResponse] + } + } +} + +sealed trait KeyCloakAuthResponse + +case class KeyCloakAuthToken(accessToken: String, + expiresIn: Int, + refreshExpiresIn: Int, + refreshToken: String, + tokenType: String, + notBeforePolicy: Int, + sessionState: String, + scope: String) extends KeyCloakAuthResponse + +case class KeyCloakAuthError(error: String, errorDescription: String) extends KeyCloakAuthResponse + +object KeyCloakAuthTokenParserProtocol extends KeyCloakAuthTokenParserProtocol + + +trait KeyCloakAuthTokenParserProtocol extends SprayJsonSupport with DefaultJsonProtocol { + implicit val responseFormat: RootJsonFormat[KeyCloakAuthResponse] = new RootJsonFormat[KeyCloakAuthResponse] { + override def write(response: KeyCloakAuthResponse): JsValue = response match { + case KeyCloakAuthToken(token, expires, _, _, tokenType, _, _, _) => JsObject( + "access_token" -> JsString(token), + "expires_in" -> JsNumber(expires), + "token_type" -> JsString(tokenType) + ) + case KeyCloakAuthError(error, desc) => JsObject( + "error" -> JsString(error), + "error_description" -> JsString(desc) + ) + } + + override def read(json: JsValue): KeyCloakAuthResponse = json match { + case JsObject(fields) if fields.contains("access_token") => + KeyCloakAuthToken( + fields.get("access_token").map(_.convertTo[String]).getOrElse(""), + fields.get("expires_in").map(_.convertTo[Int]).getOrElse(0), + fields.get("refresh_expires_in").map(_.convertTo[Int]).getOrElse(0), + fields.get("refresh_token").map(_.convertTo[String]).getOrElse(""), + fields.get("token_type").map(_.convertTo[String]).getOrElse(""), + fields.get("not-before-policy").map(_.convertTo[Int]).getOrElse(0), + fields.get("session_state").map(_.convertTo[String]).getOrElse(""), + fields.get("scope").map(_.convertTo[String]).getOrElse("") + ) + case JsObject(fields) => + KeyCloakAuthError( + fields.get("error").map(_.convertTo[String]).getOrElse(""), + fields.get("error_description").map(_.convertTo[String]).getOrElse("") + ) + } + } + + implicit val tokenFormat: RootJsonFormat[KeyCloakAuthToken] = new RootJsonFormat[KeyCloakAuthToken] { + override def write(token: KeyCloakAuthToken): JsValue = JsObject( + "access_token" -> JsString(token.accessToken), + "expires_in" -> JsNumber(token.expiresIn), + "token_type" -> JsString(token.tokenType) + ) + + override def read(json: JsValue): KeyCloakAuthToken = json match { + case JsObject(fields) if fields.contains("access_token") => + KeyCloakAuthToken( + fields.get("access_token").map(_.convertTo[String]).getOrElse(""), + fields.get("expires_in").map(_.convertTo[Int]).getOrElse(0), + fields.get("refresh_expires_in").map(_.convertTo[Int]).getOrElse(0), + fields.get("refresh_token").map(_.convertTo[String]).getOrElse(""), + fields.get("token_type").map(_.convertTo[String]).getOrElse(""), + fields.get("not-before-policy").map(_.convertTo[Int]).getOrElse(0), + fields.get("session_state").map(_.convertTo[String]).getOrElse(""), + fields.get("scope").map(_.convertTo[String]).getOrElse("") + ) + } + } + + implicit val errorFormat: RootJsonFormat[KeyCloakAuthError] = new RootJsonFormat[KeyCloakAuthError] { + override def write(error: KeyCloakAuthError): JsValue = JsObject( + "error" -> JsString(error.error), + "error_description" -> JsString(error.errorDescription) + ) + + override def read(json: JsValue): KeyCloakAuthError = json match { + case JsObject(fields) => + KeyCloakAuthError( + fields.get("error").map(_.convertTo[String]).getOrElse(""), + fields.get("error_description").map(_.convertTo[String]).getOrElse("") + ) + case _ => KeyCloakAuthError("", "") + } + } +} diff --git a/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala new file mode 100644 index 00000000..3336a133 --- /dev/null +++ b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala @@ -0,0 +1,138 @@ +package uk.gov.homeoffice.drt.keycloak + +import akka.actor.ActorSystem +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.{Accept, Authorization, OAuth2BearerToken} +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.Materializer +import akka.util.Timeout +import org.slf4j.{Logger, LoggerFactory} +import spray.json.{DefaultJsonProtocol, JsObject, JsValue, RootJsonFormat} + +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} +import scala.language.postfixOps + +abstract case class KeyCloakClient(token: String, keyCloakUrl: String, sendHttpRequest: HttpRequest => Future[HttpResponse]) + (implicit val system: ActorSystem, mat: Materializer) + extends KeyCloakUserParserProtocol { + + import system.dispatcher + import KeyCloakUserParserProtocol.KeyCloakUserFormatParser._ + + def log: Logger = LoggerFactory.getLogger(getClass) + + implicit val timeout: Timeout = Timeout(1 minute) + + def logResponse(requestName: String, resp: HttpResponse): HttpResponse = { + if (resp.status.isFailure) + log.error(s"Error when calling $requestName on KeyCloak API Status code: ${resp.status} Response:<${resp.entity.toString}>") + + resp + } + + def pipeline(method: HttpMethod, uri: String, requestName: String): Future[HttpResponse] = { + val request = HttpRequest(method, Uri(uri)) + val requestWithHeaders = request + .addHeader(Accept(MediaTypes.`application/json`)) + .addHeader(Authorization(OAuth2BearerToken(token))) + sendHttpRequest(requestWithHeaders).map { r => + logResponse(requestName, r) + r + } + } + + def getUsersForEmail(email: String): Future[Option[KeyCloakUser]] = { + val uri = keyCloakUrl + s"/users?email=$email" + log.info(s"Calling key cloak: $uri") + pipeline(HttpMethods.GET, uri, "getUsersForEmail") + .flatMap { r => Unmarshal(r).to[List[KeyCloakUser]] }.map(_.headOption) + } + + def getUsers(max: Int = 100, offset: Int = 0): Future[List[KeyCloakUser]] = { + val uri = keyCloakUrl + s"/users?max=$max&first=$offset" + log.info(s"Calling key cloak: $uri") + pipeline(HttpMethods.GET, uri, "getUsers").flatMap { r => Unmarshal(r).to[List[KeyCloakUser]] } + } + + def getAllUsers(offset: Int = 0): Seq[KeyCloakUser] = { + + val users = Await.result(getUsers(50, offset), 2 seconds) + + if (users.isEmpty) Nil else users ++ getAllUsers(offset + 50) + } + + def getUserGroups(userId: String): Future[List[KeyCloakGroup]] = { + val uri = keyCloakUrl + s"/users/$userId/groups" + log.info(s"Calling key cloak: $uri") + pipeline(HttpMethods.GET, uri, "getUserGroups").flatMap { r => Unmarshal(r).to[List[KeyCloakGroup]] } + } + + def getGroups: Future[List[KeyCloakGroup]] = { + val uri = keyCloakUrl + "/groups" + log.info(s"Calling key cloak: $uri") + pipeline(HttpMethods.GET, uri, "getGroups").flatMap { r => Unmarshal(r).to[List[KeyCloakGroup]] } + } + + def getUsersInGroup(groupName: String, max: Int = 1000): Future[List[KeyCloakUser]] = { + val futureMaybeId: Future[Option[String]] = getGroups.map(gs => gs.find(_.name == groupName).map(_.id)) + + futureMaybeId.flatMap { + case Some(id) => + val uri = keyCloakUrl + s"/groups/$id/members?max=$max" + pipeline(HttpMethods.GET, uri, "getUsersInGroup").flatMap { r => Unmarshal(r).to[List[KeyCloakUser]] } + case None => Future(List()) + } + } + + def getUsersNotInGroup(groupName: String): Future[List[KeyCloakUser]] = { + + val futureUsersInGroup: Future[List[KeyCloakUser]] = getUsersInGroup(groupName) + val futureAllUsers: Future[List[KeyCloakUser]] = getUsers() + + for { + usersInGroup <- futureUsersInGroup + allUsers <- futureAllUsers + } yield allUsers.filterNot(usersInGroup.toSet) + } + + def addUserToGroup(userId: String, groupId: String): Future[HttpResponse] = { + log.info(s"Adding $userId to $groupId") + val uri = s"$keyCloakUrl/users/$userId/groups/$groupId" + pipeline(HttpMethods.PUT, uri, "addUserToGroup") + } + + def removeUserFromGroup(userId: String, groupId: String): Future[HttpResponse] = { + log.info(s"Removing $userId from $groupId") + val uri = s"$keyCloakUrl/users/$userId/groups/$groupId" + pipeline(HttpMethods.DELETE, uri, "removeUserFromGroup") + } +} + +trait KeyCloakUserParserProtocol extends DefaultJsonProtocol with SprayJsonSupport { + + implicit object KeyCloakUserFormatParser extends RootJsonFormat[KeyCloakUser] { + override def write(obj: KeyCloakUser): JsValue = throw new Exception("KeyCloakUser writer not implemented") + + override def read(json: JsValue): KeyCloakUser = json match { + case JsObject(fields) => + KeyCloakUser( + fields.get("id").map(_.convertTo[String]).getOrElse(""), + fields.get("username").map(_.convertTo[String]).getOrElse(""), + fields.get("enabled").exists(_.convertTo[Boolean]), + fields.get("emailVerified").exists(_.convertTo[Boolean]), + fields.get("firstName").map(_.convertTo[String]).getOrElse(""), + fields.get("lastName").map(_.convertTo[String]).getOrElse(""), + fields.get("email").map(_.convertTo[String]).getOrElse("") + ) + } + } + + implicit val keyCloakGroupFormat: RootJsonFormat[KeyCloakGroup] = jsonFormat3(KeyCloakGroup) +} + + +object KeyCloakUserParserProtocol extends KeyCloakUserParserProtocol + + diff --git a/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakGroups.scala b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakGroups.scala new file mode 100644 index 00000000..1dbd34df --- /dev/null +++ b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakGroups.scala @@ -0,0 +1,52 @@ +package uk.gov.homeoffice.drt.keycloak + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + + +case class KeyCloakGroups(groups: List[KeyCloakGroup], client: KeyCloakClient) { + def usersWithGroupsCsvContent: Future[String] = { + val usersWithGroupsFuture = allUsersWithGroups(groups) + usersWithGroupsToCsv(usersWithGroupsFuture) + } + + def usersWithGroupsToCsv(usersWithGroupsFuture: Future[Map[KeyCloakUser, List[String]]]): Future[String] = { + val headerLine = "Email,First Name,Last Name,Enabled,Groups" + usersWithGroupsFuture + .map(usersToUsersWithGroups => { + val csvLines = usersToUsersWithGroups + .map { + case (user, userGroups) => + val userGroupsCsvValue = userGroups.sorted.mkString(", ") + s"""${user.email},${user.firstName},${user.lastName},${user.enabled},"$userGroupsCsvValue"""" + } + headerLine + "\n" + csvLines.mkString("\n") + }) + } + + def usersWithGroups(groups: List[KeyCloakGroup]): Future[List[(KeyCloakUser, String)]] = { + val eventualUsersWithGroupsByGroup: List[Future[List[(KeyCloakUser, String)]]] = groups.map(group => { + val eventualUsersWithGroups = client + .getUsersInGroup(group.name) + .map(_.map(user => (user, group.name))) + eventualUsersWithGroups + }) + Future.sequence(eventualUsersWithGroupsByGroup).map(_.flatten) + } + + def usersWithGroupsByUser(groups: List[KeyCloakGroup]): Future[Map[KeyCloakUser, List[String]]] = + usersWithGroups(groups).map(usersAndGroups => { + usersAndGroups.groupBy { + case (user, _) => user + }.view.mapValues(_.map { + case (_, group) => group + }).toMap + }) + + def allUsersWithGroups(groups: List[KeyCloakGroup]): Future[Map[KeyCloakUser, List[String]]] = + usersWithGroupsByUser(groups).map(groupsByUser => { + client.getAllUsers().map(u => { + u -> groupsByUser.getOrElse(u, List()) + }).toMap + }) +} diff --git a/shared/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakApi.scala b/shared/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakApi.scala new file mode 100644 index 00000000..25bf2290 --- /dev/null +++ b/shared/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakApi.scala @@ -0,0 +1,12 @@ +package uk.gov.homeoffice.drt.keycloak + +import upickle.default.{macroRW, _} + +object KeyCloakUser { + implicit val rw: ReadWriter[KeyCloakUser] = macroRW +} + +case class KeyCloakUser(id: String, username: String, enabled: Boolean, emailVerified: Boolean, firstName: String, lastName: String, email: String) + +case class KeyCloakGroup(id: String, name: String, path: String) + From e603a17de527175c1bb883bcb8296613c89bed30 Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Sat, 12 Oct 2024 11:22:50 +0100 Subject: [PATCH 2/5] DRTII-1609 Keycloak stuff --- .../drt/keycloak/KeyCloakAuth.scala | 11 ++---- .../drt/keycloak/KeyCloakClient.scala | 35 +++++++++++++------ 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuth.scala b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuth.scala index cdc8e7f6..81174185 100644 --- a/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuth.scala +++ b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuth.scala @@ -1,6 +1,5 @@ package uk.gov.homeoffice.drt.keycloak -import akka.actor.ActorSystem import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.Accept @@ -9,14 +8,12 @@ import akka.stream.Materializer import org.slf4j.{Logger, LoggerFactory} import spray.json.{DefaultJsonProtocol, JsNumber, JsObject, JsString, JsValue, RootJsonFormat} -import scala.concurrent.Future +import scala.concurrent.{ExecutionContext, Future} -abstract case class KeyCloakAuth(tokenUrl: String, clientId: String, clientSecret: String, sendHttpRequest: HttpRequest => Future[HttpResponse]) - (implicit val system: ActorSystem, mat: Materializer) +case class KeyCloakAuth(tokenUrl: String, clientId: String, clientSecret: String, sendHttpRequest: HttpRequest => Future[HttpResponse]) + (implicit ec: ExecutionContext, mat: Materializer) extends KeyCloakAuthTokenParserProtocol { - import system.dispatcher - val log: Logger = LoggerFactory.getLogger(getClass) def formData(username: String, password: String, clientId: String, clientSecret: String) = FormData(Map( @@ -55,8 +52,6 @@ case class KeyCloakAuthToken(accessToken: String, case class KeyCloakAuthError(error: String, errorDescription: String) extends KeyCloakAuthResponse -object KeyCloakAuthTokenParserProtocol extends KeyCloakAuthTokenParserProtocol - trait KeyCloakAuthTokenParserProtocol extends SprayJsonSupport with DefaultJsonProtocol { implicit val responseFormat: RootJsonFormat[KeyCloakAuthResponse] = new RootJsonFormat[KeyCloakAuthResponse] { diff --git a/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala index 3336a133..aad6835e 100644 --- a/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala +++ b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala @@ -1,6 +1,5 @@ package uk.gov.homeoffice.drt.keycloak -import akka.actor.ActorSystem import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.{Accept, Authorization, OAuth2BearerToken} @@ -11,15 +10,14 @@ import org.slf4j.{Logger, LoggerFactory} import spray.json.{DefaultJsonProtocol, JsObject, JsValue, RootJsonFormat} import scala.concurrent.duration._ -import scala.concurrent.{Await, Future} +import scala.concurrent.{Await, ExecutionContext, Future} import scala.language.postfixOps -abstract case class KeyCloakClient(token: String, keyCloakUrl: String, sendHttpRequest: HttpRequest => Future[HttpResponse]) - (implicit val system: ActorSystem, mat: Materializer) +case class KeyCloakClient(token: String, keyCloakUrl: String, sendHttpRequest: HttpRequest => Future[HttpResponse]) + (implicit val ec: ExecutionContext, mat: Materializer) extends KeyCloakUserParserProtocol { - import system.dispatcher - import KeyCloakUserParserProtocol.KeyCloakUserFormatParser._ +// import KeyCloakUserParserProtocol.KeyCloakUserFormatParser._ def log: Logger = LoggerFactory.getLogger(getClass) @@ -43,7 +41,7 @@ abstract case class KeyCloakClient(token: String, keyCloakUrl: String, sendHttpR } } - def getUsersForEmail(email: String): Future[Option[KeyCloakUser]] = { + def getUserForEmail(email: String): Future[Option[KeyCloakUser]] = { val uri = keyCloakUrl + s"/users?email=$email" log.info(s"Calling key cloak: $uri") pipeline(HttpMethods.GET, uri, "getUsersForEmail") @@ -56,13 +54,30 @@ abstract case class KeyCloakClient(token: String, keyCloakUrl: String, sendHttpR pipeline(HttpMethods.GET, uri, "getUsers").flatMap { r => Unmarshal(r).to[List[KeyCloakUser]] } } - def getAllUsers(offset: Int = 0): Seq[KeyCloakUser] = { + def getUserByUsername(username: String): Future[Option[KeyCloakUser]] = { + val uri = keyCloakUrl + s"/users?username=$username" + log.info(s"Calling key cloak: $uri") + pipeline(HttpMethods.GET, uri, "getUsersForUsername") + .flatMap { r => Unmarshal(r).to[List[KeyCloakUser]] }.map(_.headOption) + } + def getAllUsers(offset: Int = 0): Seq[KeyCloakUser] = { val users = Await.result(getUsers(50, offset), 2 seconds) - if (users.isEmpty) Nil else users ++ getAllUsers(offset + 50) } + def removeUser(userId: String): Future[HttpResponse] = { + log.info(s"Removing $userId") + val uri = s"$keyCloakUrl/users/$userId" + pipeline(HttpMethods.DELETE, uri, "removeUserFromGroup") + } + + def logUserOut(userId: String): Future[HttpResponse] = { + log.info(s"Logout $userId") + val uri = s"$keyCloakUrl/users/$userId/logout" + pipeline(HttpMethods.POST, uri, "logoutUser") + } + def getUserGroups(userId: String): Future[List[KeyCloakGroup]] = { val uri = keyCloakUrl + s"/users/$userId/groups" log.info(s"Calling key cloak: $uri") @@ -133,6 +148,6 @@ trait KeyCloakUserParserProtocol extends DefaultJsonProtocol with SprayJsonSuppo } -object KeyCloakUserParserProtocol extends KeyCloakUserParserProtocol +//object KeyCloakUserParserProtocol extends KeyCloakUserParserProtocol From 121483252b5dc23d97db6f686e7b55d8f58b45fd Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Sun, 13 Oct 2024 07:56:48 +0100 Subject: [PATCH 3/5] DRTII-1609 Manually import json formatter --- .../uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala index aad6835e..4d0df509 100644 --- a/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala +++ b/jvm/src/main/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakClient.scala @@ -17,7 +17,7 @@ case class KeyCloakClient(token: String, keyCloakUrl: String, sendHttpRequest: H (implicit val ec: ExecutionContext, mat: Materializer) extends KeyCloakUserParserProtocol { -// import KeyCloakUserParserProtocol.KeyCloakUserFormatParser._ + import KeyCloakUserFormatParser._ def log: Logger = LoggerFactory.getLogger(getClass) @@ -146,8 +146,3 @@ trait KeyCloakUserParserProtocol extends DefaultJsonProtocol with SprayJsonSuppo implicit val keyCloakGroupFormat: RootJsonFormat[KeyCloakGroup] = jsonFormat3(KeyCloakGroup) } - - -//object KeyCloakUserParserProtocol extends KeyCloakUserParserProtocol - - From b51a277663098142175f3813315808a1354ad405 Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Sat, 19 Oct 2024 11:25:48 +0100 Subject: [PATCH 4/5] DRTII-1609 import keycloak tests --- .../drt/keycloak/KeyCloakAuthSpec.scala | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 jvm/src/test/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuthSpec.scala diff --git a/jvm/src/test/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuthSpec.scala b/jvm/src/test/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuthSpec.scala new file mode 100644 index 00000000..08baaa2f --- /dev/null +++ b/jvm/src/test/scala/uk/gov/homeoffice/drt/keycloak/KeyCloakAuthSpec.scala @@ -0,0 +1,95 @@ +package uk.gov.homeoffice.drt.keycloak + + +import akka.actor.ActorSystem +import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpRequest, HttpResponse} +import org.specs2.mutable.Specification + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContextExecutor, Future} + +class KeyCloakAuthSpec extends Specification with KeyCloakAuthTokenParserProtocol { + implicit val system: ActorSystem = ActorSystem("KeyCloakAuthSpec") + implicit val ec: ExecutionContextExecutor = system.dispatcher + + val keyCloakUrl = "https://keycloak" + + val tokenResponseJson: String = + s"""{ + | "access_token": "token", + | "expires_in": 86400, + | "refresh_expires_in": 86400, + | "refresh_token": "refresh token", + | "token_type": "bearer", + | "not-before-policy": 0, + | "session_state": "session", + | "scope": "profile email" + |}""".stripMargin + + + "When parsing keycloak JSON token I should get back a case class representation of the token" >> { + + import spray.json._ + + val expected = KeyCloakAuthToken( + "token", + 86400, + 86400, + "refresh token", + "bearer", + 0, + "session", + "profile email" + ) + + val result: KeyCloakAuthToken = tokenResponseJson.parseJson.convertTo[KeyCloakAuthToken] + + result === expected + } + + "When logging into Keycloak with a correct username and password then I should get a token back" >> { + + val sendHttpRequest: HttpRequest => Future[HttpResponse] = + _ => Future.successful(HttpResponse().withEntity(HttpEntity(ContentTypes.`application/json`, tokenResponseJson))) + val auth = KeyCloakAuth("tokenurl", "clientId", "client secret", sendHttpRequest) + + val expected = KeyCloakAuthToken( + "token", + 86400, + 86400, + "refresh token", + "bearer", + 0, + "session", + "profile email" + ) + + val token = Await.result(auth.getToken("user", "pass"), 30.seconds) + + token === expected + } + + "When logging into Keycloak with an invalid username and password then I should handle the response" >> { + + val sendHttpRequest: HttpRequest => Future[HttpResponse] = (_: HttpRequest) => { + Future(HttpResponse(400).withEntity(HttpEntity( + ContentTypes.`application/json`, + """| + |{ + | "error": "invalid_grant", + | "error_description": "Invalid user credentials" + |} + """.stripMargin + ))) + } + + val auth = KeyCloakAuth("tokenurl", "clientId", "client secret", sendHttpRequest) + + val expected = KeyCloakAuthError("invalid_grant", "Invalid user credentials") + + val errorResponse = Await.result(auth.getToken("user", "pass"), 30.seconds) + + errorResponse === expected + } + +} From 4aee507f9233cb831e50e5cfa6e9006aeb2aa112 Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Mon, 21 Oct 2024 13:47:18 +0100 Subject: [PATCH 5/5] DRTII-1609 Add Api access roles --- .../main/scala/uk/gov/homeoffice/drt/auth/Roles.scala | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/shared/src/main/scala/uk/gov/homeoffice/drt/auth/Roles.scala b/shared/src/main/scala/uk/gov/homeoffice/drt/auth/Roles.scala index 25e7ad49..b8e95d55 100644 --- a/shared/src/main/scala/uk/gov/homeoffice/drt/auth/Roles.scala +++ b/shared/src/main/scala/uk/gov/homeoffice/drt/auth/Roles.scala @@ -46,6 +46,8 @@ object Roles { AccessOnlyProd, AccessOnlyPreprod, NationalView, + ApiQueueAccess, + ApiFlightAccess, ) ++ portRoles ++ Set(TEST, TEST2) def parse(roleName: String): Option[Role] = availableRoles.find(role => role.name.toLowerCase == roleName.toLowerCase) @@ -104,6 +106,14 @@ object Roles { sealed trait PortAccess extends Role + case object ApiQueueAccess extends Role { + override val name: String = "api-queue-access" + } + + case object ApiFlightAccess extends Role { + override val name: String = "api-flight-access" + } + case object TEST extends PortAccess { override val name: String = "TEST" }