diff --git a/src/main/scala/ca/ferlab/ferload/client/Main.scala b/src/main/scala/ca/ferlab/ferload/client/Main.scala index 0da69cd..7e1cd7b 100644 --- a/src/main/scala/ca/ferlab/ferload/client/Main.scala +++ b/src/main/scala/ca/ferlab/ferload/client/Main.scala @@ -11,7 +11,7 @@ import picocli.CommandLine.Command import java.io.File @Command(name = "ferload-client", mixinStandardHelpOptions = true, - version = Array("2.0.0"), + version = Array("2.1.0"), description = Array("Official Ferload Client command line interface for files download."), subcommands = Array(classOf[Configure], classOf[Download])) class Main extends Runnable { diff --git a/src/main/scala/ca/ferlab/ferload/client/clients/BaseHttpClient.scala b/src/main/scala/ca/ferlab/ferload/client/clients/BaseHttpClient.scala index 0810dd3..6c76df9 100644 --- a/src/main/scala/ca/ferlab/ferload/client/clients/BaseHttpClient.scala +++ b/src/main/scala/ca/ferlab/ferload/client/clients/BaseHttpClient.scala @@ -8,7 +8,6 @@ import org.apache.http.util.EntityUtils import org.json.JSONObject import scala.jdk.CollectionConverters.MapHasAsScala // scala 2.13 -// import scala.collection.JavaConverters._ // scala 2.12 abstract class BaseHttpClient { @@ -27,7 +26,9 @@ abstract class BaseHttpClient { } protected def formatExceptionMessage(message: String, status: Int, body: Option[String]): String = { - s"$message, code: $status, message:\n${body.getOrElse("")}" + val msg = body.map(r => new JSONObject(r).get("msg")) + + s"$message, code: $status, message:\n${msg.getOrElse("")}" } protected def toMap(body: Option[String], lineContents: Seq[LineContent]): Map[LineContent, String] = { diff --git a/src/main/scala/ca/ferlab/ferload/client/clients/FerloadClient.scala b/src/main/scala/ca/ferlab/ferload/client/clients/FerloadClient.scala index 14c5ef6..ff02634 100644 --- a/src/main/scala/ca/ferlab/ferload/client/clients/FerloadClient.scala +++ b/src/main/scala/ca/ferlab/ferload/client/clients/FerloadClient.scala @@ -27,7 +27,7 @@ class FerloadClient(userConfig: UserConfig) extends BaseHttpClient with IFerload val (body, status) = executeHttpRequest(httpRequest) status match { case 200 => toMap(body, manifestContent.lines) - case 403 => throw new IllegalStateException(formatExceptionMessage("No enough access rights to download the following files", status, body)) + case 403 => throw new UnauthorizedException(formatExceptionMessage("No enough access rights to download the following files", status, body)) case _ => throw new IllegalStateException(formatExceptionMessage("Failed to retrieve download link(s)", status, body)) } } diff --git a/src/main/scala/ca/ferlab/ferload/client/clients/KeycloakClient.scala b/src/main/scala/ca/ferlab/ferload/client/clients/KeycloakClient.scala index 72e1bb3..7d0e186 100644 --- a/src/main/scala/ca/ferlab/ferload/client/clients/KeycloakClient.scala +++ b/src/main/scala/ca/ferlab/ferload/client/clients/KeycloakClient.scala @@ -31,7 +31,7 @@ class KeycloakClient(config: UserConfig) extends BaseHttpClient with IKeycloak { if (StringUtils.isNoneBlank(username, password)) { getRPT(getAccessToken(username, password)) } else if(StringUtils.isNotBlank(refreshToken)) { - getRefreshedToken(refreshToken) + getRefreshedToken(refreshToken)(realm = config.get(TokenRealm), client = config.get(TokenClientId)) } else { throw new IllegalStateException("No valid user credentials") } @@ -49,7 +49,7 @@ class KeycloakClient(config: UserConfig) extends BaseHttpClient with IKeycloak { executeDeviceFetch(request) } - override def getUserDeviceToken(deviceCode: String, expiresIn: Int = MAX_TOKEN_EXPIRE): String = { + override def getUserDeviceToken(deviceCode: String, expiresIn: Int = MAX_TOKEN_EXPIRE): (String, String) = { val request = new HttpPost(s"${config.get(KeycloakUrl)}/realms/${config.get(KeycloakRealm)}/protocol/openid-connect/token") request.addHeader("Content-Type", "application/x-www-form-urlencoded") @@ -63,6 +63,12 @@ class KeycloakClient(config: UserConfig) extends BaseHttpClient with IKeycloak { executeWithRetry(request, expiresIn) } + override def getRefreshedTokens(refreshToken: String)(realm: String, client: String): (String, String) = { + val resp = refreshCredentials(refreshToken: String)(realm: String, client: String) + + (resp.getString("access_token"), resp.getString("refresh_token")) + } + private def getAccessToken(username: String, password: String) = { val request = new HttpPost(s"${config.get(KeycloakUrl)}/realms/${config.get(KeycloakRealm)}/protocol/openid-connect/token") @@ -74,14 +80,20 @@ class KeycloakClient(config: UserConfig) extends BaseHttpClient with IKeycloak { request.setEntity(new UrlEncodedFormEntity(form, charset)) - execute(request) + execute(request).getString("access_token") } - private def getRefreshedToken(refreshToken: String) = { - val request = new HttpPost(s"${config.get(KeycloakUrl)}/realms/${config.get(TokenRealm)}/protocol/openid-connect/token") + private def getRefreshedToken(refreshToken: String)(realm: String, client: String) = { + val refreshedCreds = refreshCredentials(refreshToken: String)(realm: String, client: String) + + refreshedCreds.getString("access_token") + } + + private def refreshCredentials(refreshToken: String)(realm: String, client: String) = { + val request = new HttpPost(s"${config.get(KeycloakUrl)}/realms/$realm/protocol/openid-connect/token") val form = new java.util.ArrayList[BasicNameValuePair]() - form.add(new BasicNameValuePair("client_id", config.get(TokenClientId))) + form.add(new BasicNameValuePair("client_id", client)) form.add(new BasicNameValuePair("grant_type", "refresh_token")) form.add(new BasicNameValuePair("refresh_token", refreshToken)) @@ -100,16 +112,16 @@ class KeycloakClient(config: UserConfig) extends BaseHttpClient with IKeycloak { request.setEntity(new UrlEncodedFormEntity(form, charset)) - execute(request) + execute(request).getString("access_token") } - private def execute(request: HttpPost) = { + private def execute(request: HttpPost): JSONObject = { val (body, status) = executeHttpRequest(request) - val token = status match { - case 200 => body.map(new JSONObject(_)).get.getString("access_token") + val parsedBody = status match { + case 200 => body.map(new JSONObject(_)).get case _ => throw new IllegalStateException(formatExceptionMessage("Failed to get access token", status, body)) } - token + parsedBody } private def executeNoVerify(request: HttpPost) = { @@ -142,7 +154,8 @@ class KeycloakClient(config: UserConfig) extends BaseHttpClient with IKeycloak { import cats.effect.unsafe.implicits.global retryingOnFailures(policyDelay.join(policyMaxRetries(expiresIn)), isResultOk, onFailure)(IO(executeNoVerify(request))) .map { case(_, body) => - body.map(new JSONObject(_)).get.getString("access_token") + val parsedBody = body.map(new JSONObject(_)).get + (parsedBody.getString("access_token"), parsedBody.getString("refresh_token")) }.unsafeRunSync() } diff --git a/src/main/scala/ca/ferlab/ferload/client/clients/UnauthorizedException.scala b/src/main/scala/ca/ferlab/ferload/client/clients/UnauthorizedException.scala new file mode 100644 index 0000000..dfde764 --- /dev/null +++ b/src/main/scala/ca/ferlab/ferload/client/clients/UnauthorizedException.scala @@ -0,0 +1,7 @@ +package ca.ferlab.ferload.client.clients + +import scala.util.control.NoStackTrace + +class UnauthorizedException(message: String) extends RuntimeException with NoStackTrace { + override def getMessage: String = message +} diff --git a/src/main/scala/ca/ferlab/ferload/client/clients/inf/IKeycloak.scala b/src/main/scala/ca/ferlab/ferload/client/clients/inf/IKeycloak.scala index c3137e3..5c3b7c1 100644 --- a/src/main/scala/ca/ferlab/ferload/client/clients/inf/IKeycloak.scala +++ b/src/main/scala/ca/ferlab/ferload/client/clients/inf/IKeycloak.scala @@ -7,7 +7,9 @@ trait IKeycloak { def getDevice: JSONObject - def getUserDeviceToken(deviceCode: String, expiresIn: Int): String + def getUserDeviceToken(deviceCode: String, expiresIn: Int): (String, String) + + def getRefreshedTokens(refreshToken: String)(realm: String, client: String): (String, String) def isValidToken(token: String): Boolean } diff --git a/src/main/scala/ca/ferlab/ferload/client/commands/Download.scala b/src/main/scala/ca/ferlab/ferload/client/commands/Download.scala index b13eba5..d79ac12 100644 --- a/src/main/scala/ca/ferlab/ferload/client/commands/Download.scala +++ b/src/main/scala/ca/ferlab/ferload/client/commands/Download.scala @@ -14,7 +14,7 @@ import picocli.CommandLine.{Command, IExitCodeGenerator, Option} import java.io.{File, FileReader} import java.util.Optional -import java.util.stream.Collectors.{summingInt, toList} +import java.util.stream.Collectors.toList import scala.jdk.CollectionConverters._ import scala.util.{Failure, Success, Try, Using} @@ -62,6 +62,7 @@ class Download(userConfig: UserConfig, val authMethod = userConfig.get(Method) var token = userConfig.get(Token) + val refreshToken = userConfig.get(RefreshToken) if (KeycloakClient.AUTH_METHOD_PASSWORD == authMethod) { if (!keycloak.isValidToken(token)) { val passwordStr = password.orElseGet(() => readLine("-p", "", password = true)) @@ -82,17 +83,34 @@ class Download(userConfig: UserConfig, keycloak.getUserCredentials(null, null, userConfig.get(Token)) } } else if (KeycloakClient.AUTH_DEVICE == authMethod) { - val resp = CommandBlock("Retrieve device token", successEmoji, padding) { - keycloak.getDevice - } + if(keycloak.isValidToken(token)) { + CommandBlock("Re-use user device credentials", successEmoji, padding) { + "" + } + } else { + val (newToken, newRefreshToken) = if(!keycloak.isValidToken(refreshToken)){ + val resp = CommandBlock("Retrieve device token", successEmoji, padding) { + keycloak.getDevice + } + + CommandBlock("Copy/Paste this URL in your browser and login please: ", successEmoji, padding) { + println(resp.getString("verification_uri_complete")) + } + keycloak.getUserDeviceToken(resp.getString("device_code"), resp.getInt("expires_in")) + } else { + CommandBlock("Refresh device credentials", successEmoji, padding) { + "" + } + keycloak.getRefreshedTokens(refreshToken)(userConfig.get(KeycloakRealm), userConfig.get(KeycloakAudience)) + } - token = CommandBlock("Copy/Paste this URL in your browser and login please: ", successEmoji, padding) { - println(resp.getString("verification_uri_complete")) - val newToken = keycloak.getUserDeviceToken(resp.getString("device_code"), resp.getInt("expires_in")) userConfig.set(Token, newToken) + userConfig.set(RefreshToken, newRefreshToken) userConfig.save() - newToken + token = newToken + } + } val links: Map[LineContent, String] = CommandBlock("Retrieve Ferload download link(s)", successEmoji, padding) { diff --git a/src/main/scala/ca/ferlab/ferload/client/configurations/UserConfigName.scala b/src/main/scala/ca/ferlab/ferload/client/configurations/UserConfigName.scala index 53696df..09fa3d0 100644 --- a/src/main/scala/ca/ferlab/ferload/client/configurations/UserConfigName.scala +++ b/src/main/scala/ca/ferlab/ferload/client/configurations/UserConfigName.scala @@ -8,6 +8,8 @@ case object Username extends UserConfigName("username") case object Token extends UserConfigName("token") +case object RefreshToken extends UserConfigName("refresh_token") + case object KeycloakUrl extends UserConfigName("keycloak-url") case object KeycloakRealm extends UserConfigName("keycloak-realm") diff --git a/src/test/scala/ca/ferlab/ferload/client/commands/DownloadTest.scala b/src/test/scala/ca/ferlab/ferload/client/commands/DownloadTest.scala index 8b6a000..b5bea74 100644 --- a/src/test/scala/ca/ferlab/ferload/client/commands/DownloadTest.scala +++ b/src/test/scala/ca/ferlab/ferload/client/commands/DownloadTest.scala @@ -67,7 +67,9 @@ class DownloadTest extends AnyFunSuite with BeforeAndAfter { override def getDevice: JSONObject = ??? - override def getUserDeviceToken(deviceCode: String, expiresIn: Int): String = ??? + override def getUserDeviceToken(deviceCode: String, expiresIn: Int): (String, String) = ??? + + override def getRefreshedTokens(refreshToken: String)(realm: String, client: String): (String, String) = ??? } val mockKeycloakValidTokenInf: IKeycloak = new IKeycloak { @@ -77,7 +79,9 @@ class DownloadTest extends AnyFunSuite with BeforeAndAfter { override def getDevice: JSONObject = ??? - override def getUserDeviceToken(deviceCode: String, expiresIn: Int): String = ??? + override def getUserDeviceToken(deviceCode: String, expiresIn: Int): (String, String) = ??? + + override def getRefreshedTokens(refreshToken: String)(realm: String, client: String): (String, String) = ??? } val mockFerload: IFerload = new IFerload {