From 15155767013c5c0b0ccfe37aebd327ab8a3d5b5c Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 22 Feb 2024 15:01:06 -0500 Subject: [PATCH] fix: CQDG-626 enable device authentication --- README.md | 8 ++-- .../scala/bio/ferlab/ferload/Config.scala | 48 ++++++++++++++----- .../ferload/endpoints/ConfigEndpoint.scala | 6 ++- .../ferlab/ferload/model/FerloadConfig.scala | 2 +- .../endpoints/ConfigEndpointsSpec.scala | 30 +++++++++++- .../services/AuthorizationServiceSpec.scala | 16 +++---- .../services/ResourceServiceSpec.scala | 10 ++-- 7 files changed, 89 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 319f288..fd372d3 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,15 @@ Keyckloak Authentication server information : - `AUTH_REALM` : Keycloak Realm - `AUTH_CLIENT_ID` : Id of the client that contains resource definition and permissions - `AUTH_CLIENT_SECRET` : Secret of the client that contains resource definition and permissions +- `AUTH_DEVICE_CLIENT_ID` : Id of the client with OAuth 2 device authorization granted. Required if `FERLOAD_CLIENT_METHOD` is `device`. +- `AUTH_DEVICE_CLIENT_SECRET` : Secret of the client with OAuth 2 device authorization granted. Required if `FERLOAD_CLIENT_METHOD` is `device`. - `AUTH_RESOURCES_POLICY_GLOBAL_NAME` : Name of the resource a user should have access to be able to download all files. Works only with endpoints that fetch files by urls. Can be empty. Ferload Client: This section is used to configure ferload clients taht can be installed to download files by requesting ferload endpoints. -- `FERLOAD_CLIENT_METHOD` : 2 possible values : `token`or `password`. Default `token`. -- `FERLOAD_CLIENT_CLIENT_ID` : client id to use to authenticate user (`password` method) or refesh token (`token` method). -- `FERLOAD_CLIENT_TOKEN_LINK` : url to use to fetch new token in case of `token` method. +- `FERLOAD_CLIENT_METHOD` : 3 possible values : `token`or `password` or `device`. Default `token`. +- `FERLOAD_CLIENT_CLIENT_ID` : client id to use to authenticate user (`password` method) or refesh token (`token` or `device` methods). +- `FERLOAD_CLIENT_TOKEN_LINK` : url to use to fetch new token in case of `token` or `device` method. - `FERLOAD_CLIENT_TOKEN_HELPER` : text to display in ferload client to explain how to get a new token. Used only if `FERLOAD_CLIENT_METHOD` is `token`. AWS S3 information : diff --git a/src/main/scala/bio/ferlab/ferload/Config.scala b/src/main/scala/bio/ferlab/ferload/Config.scala index f5f02d4..d4f1933 100644 --- a/src/main/scala/bio/ferlab/ferload/Config.scala +++ b/src/main/scala/bio/ferlab/ferload/Config.scala @@ -1,5 +1,7 @@ package bio.ferlab.ferload +import bio.ferlab.ferload.FerloadClientConfig.DEVICE + case class Config(auth: AuthConfig, http: HttpConfig, s3Config: S3Config, drsConfig: DrsConfig, ferloadClientConfig: FerloadClientConfig) case class S3Config( @@ -74,15 +76,45 @@ object HttpConfig { } } -case class AuthConfig(authUrl: String, realm: String, clientId: String, clientSecret: String, resourcesGlobalName: Option[String]) { +case class AuthConfig( + authUrl: String, + realm: String, + clientId: String, + clientSecret: String, + deviceClientId: Option[String], + deviceClientSecret: Option[String], + resourcesGlobalName: Option[String] + ) { val baseUri = s"$authUrl/realms/$realm" } +object AuthConfig { + def load(): AuthConfig = { + val f = AuthConfig( + sys.env("AUTH_URL"), + sys.env("AUTH_REALM"), + sys.env("AUTH_CLIENT_ID"), + sys.env("AUTH_CLIENT_SECRET"), + sys.env.get("AUTH_DEVICE_CLIENT_ID"), + sys.env.get("AUTH_DEVICE_CLIENT_SECRET"), + sys.env.get("AUTH_RESOURCES_POLICY_GLOBAL_NAME") + ) + if (sys.env.getOrElse("FERLOAD_CLIENT_METHOD", "token") == DEVICE && f.deviceClientId.isEmpty) { + throw new IllegalArgumentException(s"When FERLOAD_CLIENT_METHOD is `device`, AUTH_DEVICE_CLIENT_ID must be provided") + } + if (sys.env.getOrElse("FERLOAD_CLIENT_METHOD", "token") == DEVICE && f.deviceClientSecret.isEmpty) { + throw new IllegalArgumentException(s"When FERLOAD_CLIENT_METHOD is `device`, AUTH_DEVICE_CLIENT_SECRET must be provided") + } + f + } +} + case class FerloadClientConfig(method: String, clientId: String, tokenLink: Option[String], tokenHelper: Option[String]) object FerloadClientConfig { val TOKEN: String = "token" val PASSWORD: String = "password" + val DEVICE: String = "device" def load(): FerloadClientConfig = { val f = FerloadClientConfig( sys.env.getOrElse("FERLOAD_CLIENT_METHOD", "token"), @@ -90,11 +122,11 @@ object FerloadClientConfig { sys.env.get("FERLOAD_CLIENT_TOKEN_LINK"), sys.env.get("FERLOAD_CLIENT_TOKEN_HELPER") ) - if (f.method != TOKEN && f.method != PASSWORD) { + if (f.method != TOKEN && f.method != PASSWORD && f.method != DEVICE) { throw new IllegalArgumentException(s"FERLOAD_CLIENT_METHOD must be $TOKEN or $PASSWORD") } - if (f.method == TOKEN && f.tokenLink.isEmpty) { - throw new IllegalArgumentException(s"FERLOAD_CLIENT_TOKEN_LINK must be set when FERLOAD_CLIENT_METHOD is $TOKEN") + if ((f.method == TOKEN || f.method == DEVICE) && f.tokenLink.isEmpty) { + throw new IllegalArgumentException(s"FERLOAD_CLIENT_TOKEN_LINK must be set when FERLOAD_CLIENT_METHOD is $TOKEN or $DEVICE") } f } @@ -104,13 +136,7 @@ object Config { def load(): Config = { Config( - AuthConfig( - sys.env("AUTH_URL"), - sys.env("AUTH_REALM"), - sys.env("AUTH_CLIENT_ID"), - sys.env("AUTH_CLIENT_SECRET"), - sys.env.get("AUTH_RESOURCES_POLICY_GLOBAL_NAME") - ), + AuthConfig.load(), HttpConfig.load(), S3Config.load(), DrsConfig.load(), diff --git a/src/main/scala/bio/ferlab/ferload/endpoints/ConfigEndpoint.scala b/src/main/scala/bio/ferlab/ferload/endpoints/ConfigEndpoint.scala index 06fdf1a..3644b98 100644 --- a/src/main/scala/bio/ferlab/ferload/endpoints/ConfigEndpoint.scala +++ b/src/main/scala/bio/ferlab/ferload/endpoints/ConfigEndpoint.scala @@ -1,7 +1,7 @@ package bio.ferlab.ferload.endpoints -import bio.ferlab.ferload.{Config, FerloadClientConfig} import bio.ferlab.ferload.model.{FerloadConfig, KeycloakConfig, TokenConfig} +import bio.ferlab.ferload.{Config, FerloadClientConfig} import cats.effect.IO import io.circe.generic.auto.* import sttp.tapir.* @@ -24,6 +24,10 @@ object ConfigEndpoint: } else if (config.ferloadClientConfig.method == FerloadClientConfig.PASSWORD) { val kc = KeycloakConfig(config.auth.authUrl, config.auth.realm, config.ferloadClientConfig.clientId, config.auth.clientId) IO.pure(FerloadConfig(config.ferloadClientConfig.method, Some(kc), None)) + } else if (config.ferloadClientConfig.method == FerloadClientConfig.DEVICE) { + val kc = KeycloakConfig(config.auth.authUrl, config.auth.realm, config.ferloadClientConfig.clientId, config.auth.clientId, config.auth.deviceClientId) + val deviceConfig = TokenConfig(config.auth.realm, config.ferloadClientConfig.clientId, config.ferloadClientConfig.tokenLink.get, config.ferloadClientConfig.tokenHelper) + IO.pure(FerloadConfig(config.ferloadClientConfig.method, Some(kc), Some(deviceConfig))) } else { IO.raiseError(new IllegalStateException(s"Invalid configuration type ${config.ferloadClientConfig.method}")) diff --git a/src/main/scala/bio/ferlab/ferload/model/FerloadConfig.scala b/src/main/scala/bio/ferlab/ferload/model/FerloadConfig.scala index 3c3073f..bd5b373 100644 --- a/src/main/scala/bio/ferlab/ferload/model/FerloadConfig.scala +++ b/src/main/scala/bio/ferlab/ferload/model/FerloadConfig.scala @@ -6,6 +6,6 @@ import scala.annotation.targetName case class FerloadConfig(method: String, keycloak: Option[KeycloakConfig], tokenConfig: Option[TokenConfig]) -case class KeycloakConfig(url: String, realm: String, `client-id`: String, audience: String) +case class KeycloakConfig(url: String, realm: String, `client-id`: String, audience: String, `device-client`: Option[String] = None) case class TokenConfig(realm:String, `client-id`: String, link: String, helper: Option[String]) \ No newline at end of file diff --git a/src/test/scala/bio/ferlab/ferload/endpoints/ConfigEndpointsSpec.scala b/src/test/scala/bio/ferlab/ferload/endpoints/ConfigEndpointsSpec.scala index a8f3ab0..ff08ad6 100644 --- a/src/test/scala/bio/ferlab/ferload/endpoints/ConfigEndpointsSpec.scala +++ b/src/test/scala/bio/ferlab/ferload/endpoints/ConfigEndpointsSpec.scala @@ -19,7 +19,7 @@ class ConfigEndpointsSpec extends AnyFlatSpec with Matchers with EitherValues: "config" should "return expected config for password method" in { //given val config = Config( - AuthConfig("http://localhost:8080", "realm", "clientId", "clientSecret", None), + AuthConfig("http://localhost:8080", "realm", "clientId", "clientSecret", None, None, None), HttpConfig("localhost", 9090), S3Config(Some("accessKey"), Some("secretKey"), Some("endpoint"), Some("bucket"), false, Some("region"), 3600), DrsConfig("ferlaod", "Ferload", "ferload.ferlab.bio", "1.3.0", "Ferlab", "https://ferlab.bio"), @@ -41,7 +41,7 @@ class ConfigEndpointsSpec extends AnyFlatSpec with Matchers with EitherValues: it should "return expected config for token method" in { //given val config = Config( - AuthConfig("http://localhost:8080", "realm", "clientId", "clientSecret", None), + AuthConfig("http://localhost:8080", "realm", "clientId", "clientSecret", None, None, None), HttpConfig("localhost", 9090), S3Config(Some("accessKey"), Some("secretKey"), Some("endpoint"), Some("bucket"), false, Some("region"), 3600), DrsConfig("ferlaod", "Ferload", "ferload.ferlab.bio", "1.3.0", "Ferlab", "https://ferlab.bio"), @@ -58,4 +58,30 @@ class ConfigEndpointsSpec extends AnyFlatSpec with Matchers with EitherValues: val expected = FerloadConfig(FerloadClientConfig.TOKEN, None, Some(TokenConfig("realm", "ferloadClientId", "https://ferload.ferlab.bio/token", Some("Please copy / paste this url in your browser to get a new authentication token.")))) response.map(_.body.value shouldBe expected).unwrap + } + + it should "return expected config for device method" in { + //given + val config = Config( + AuthConfig("http://localhost:8080", "realm", "clientId", "clientSecret", Some("deviceClient"), Some("deviceClientSecret"), None), + HttpConfig("localhost", 9090), + S3Config(Some("accessKey"), Some("secretKey"), Some("endpoint"), Some("bucket"), false, Some("region"), 3600), + DrsConfig("ferlaod", "Ferload", "ferload.ferlab.bio", "1.3.0", "Ferlab", "https://ferlab.bio"), + FerloadClientConfig(FerloadClientConfig.DEVICE, "ferloadClientId", Some("https://ferload.ferlab.bio/token"), Some("Please copy / paste this url in your browser to get a new authentication token.")) + ) + val backendStub = TapirStubInterpreter(SttpBackendStub(new CatsMonadError[IO]())) + .whenServerEndpointRunLogic(configServerEndpoint(config)) + .backend() + // when + val response = basicRequest + .get(uri"http://test.com/config") + .response(asJson[FerloadConfig]) + .send(backendStub) + + val expected = FerloadConfig( + FerloadClientConfig.DEVICE, + Some(KeycloakConfig("http://localhost:8080", "realm", "ferloadClientId", "clientId", Some("deviceClient"))), + Some(TokenConfig("realm", "ferloadClientId", "https://ferload.ferlab.bio/token", Some("Please copy / paste this url in your browser to get a new authentication token."))) + ) + response.map(_.body.value shouldBe expected).unwrap } \ No newline at end of file diff --git a/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala b/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala index 9bbc741..3524357 100644 --- a/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala +++ b/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala @@ -19,7 +19,7 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu .whenRequestMatches(_ => true) .thenRespond(""" {"access_token": "E123456", "expires_in": 65, "refresh_expires_in": 0, "token_type" : "bearer"} """) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val authorizationService = new AuthorizationService(authConfig, testingBackend) authorizationService.requestPartyToken("https://ferlab.bio", Seq("FI1")).unwrap shouldBe "E123456" testingBackend.allInteractions.size shouldBe 1 @@ -34,7 +34,7 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu .whenRequestMatches(_ => true) .thenRespond(""" {"error": "invalid_token"}""", statusCode = StatusCode.Forbidden) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val authorizationService = new AuthorizationService(authConfig, testingBackend) a[HttpError[_]] should be thrownBy { authorizationService.requestPartyToken("https://ferlab.bio", Seq("FI1")).unwrap @@ -66,7 +66,7 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu |} """.stripMargin) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val authorizationService = new AuthorizationService(authConfig, testingBackend) val resp = authorizationService.introspectPartyToken("E123456").unwrap @@ -97,7 +97,7 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu |} """.stripMargin) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val authorizationService = new AuthorizationService(authConfig, testingBackend) @@ -128,7 +128,7 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu |} """.stripMargin) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val authorizationService = new AuthorizationService(authConfig, testingBackend) @@ -161,7 +161,7 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu | ] |} """.stripMargin) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val authorizationService = new AuthorizationService(authConfig, testingBackend) @@ -195,7 +195,7 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu | ] |} """.stripMargin) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val authorizationService = new AuthorizationService(authConfig, testingBackend) @@ -229,7 +229,7 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu | ] |} """.stripMargin) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val authorizationService = new AuthorizationService(authConfig, testingBackend) diff --git a/src/test/scala/bio/ferlab/ferload/services/ResourceServiceSpec.scala b/src/test/scala/bio/ferlab/ferload/services/ResourceServiceSpec.scala index 126cd9f..964c9e1 100644 --- a/src/test/scala/bio/ferlab/ferload/services/ResourceServiceSpec.scala +++ b/src/test/scala/bio/ferlab/ferload/services/ResourceServiceSpec.scala @@ -18,7 +18,7 @@ class ResourceServiceSpec extends AnyFlatSpec with Matchers with EitherValues { .whenRequestMatches(_ => true) .thenRespond(""" {"access_token": "E123456", "expires_in": 65, "refresh_expires_in": 0, "token_type" : "bearer"} """) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val resourceService = new ResourceService(authConfig, testingBackend) resourceService.clientToken().unwrap shouldBe PartyToken("E123456", 65, 0, None, "bearer") @@ -29,7 +29,7 @@ class ResourceServiceSpec extends AnyFlatSpec with Matchers with EitherValues { .whenRequestMatches(_ => true) .thenRespond(""" {"error": "invalid_token"}""", statusCode = StatusCode.Forbidden) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val resourceService = new ResourceService(authConfig, testingBackend) val error = the[HttpError[_]] thrownBy { @@ -48,7 +48,7 @@ class ResourceServiceSpec extends AnyFlatSpec with Matchers with EitherValues { .whenRequestMatches(r => r.uri.path == Seq("realms", "realm", "authz", "protection", "resource_set", "F1") && r.method.method == "GET") .thenRespond("", statusCode = StatusCode.Ok) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val resourceService = new ResourceService(authConfig, testingBackend) resourceService.existResource("F1").unwrap shouldBe StatusCode.Ok @@ -61,7 +61,7 @@ class ResourceServiceSpec extends AnyFlatSpec with Matchers with EitherValues { .whenRequestMatches(r => r.uri.path == Seq("realms", "realm", "authz", "protection", "resource_set", "F1")) .thenRespond("", statusCode = StatusCode.NotFound) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val resourceService = new ResourceService(authConfig, testingBackend) resourceService.existResource("F1").unwrap shouldBe StatusCode.NotFound @@ -118,7 +118,7 @@ class ResourceServiceSpec extends AnyFlatSpec with Matchers with EitherValues { |} | """.stripMargin, statusCode = StatusCode.Ok) ) - val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None) + val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None, None) val resourceService = new ResourceService(authConfig, testingBackend) resourceService.getResourceById("FI1").unwrap shouldBe ReadResource(