Skip to content

Commit

Permalink
fix: CQDG-626 enable device authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
adipaul1981 committed Feb 22, 2024
1 parent d473084 commit 5619fd7
Show file tree
Hide file tree
Showing 8 changed files with 91 additions and 33 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/publish_with_latest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
distribution: 'corretto'
java-version: '17'
cache: 'sbt'
- name: Run tests
run: sbt test
- name: Assembly
run: sbt assembly
- name: Run tests
run: sbt test
- name: Push the image on the registry
uses: Ferlab-Ste-Justine/action-push-image@v2
with:
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 :
Expand Down
48 changes: 37 additions & 11 deletions src/main/scala/bio/ferlab/ferload/Config.scala
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -74,27 +76,57 @@ 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"),
sys.env("FERLOAD_CLIENT_CLIENT_ID"),
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
}
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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.model.{DeviceConfig, FerloadConfig, KeycloakConfig, TokenConfig}
import cats.effect.IO
import io.circe.generic.auto.*
import sttp.tapir.*
Expand All @@ -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}"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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"),
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit 5619fd7

Please sign in to comment.