diff --git a/delta/app/src/main/resources/app.conf b/delta/app/src/main/resources/app.conf index 1417769624..e5c129917f 100644 --- a/delta/app/src/main/resources/app.conf +++ b/delta/app/src/main/resources/app.conf @@ -50,6 +50,8 @@ app { # Allows to return a redirection when fetching a resource with the `Accept` header # set to `text/html` enable-redirects = false + # base to use to reconstruct resource identifiers in the proxy pass + resolve-base = "http://localhost:8081" } # Json-LD Api configuration diff --git a/delta/plugins/elasticsearch/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/ElasticSearchPluginModule.scala b/delta/plugins/elasticsearch/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/ElasticSearchPluginModule.scala index 0d3f08a5d9..5eec1f42fe 100644 --- a/delta/plugins/elasticsearch/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/ElasticSearchPluginModule.scala +++ b/delta/plugins/elasticsearch/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/ElasticSearchPluginModule.scala @@ -279,9 +279,10 @@ class ElasticSearchPluginModule(priority: Int) extends ModuleDef { s: Scheduler, ordering: JsonKeyOrdering, rcr: RemoteContextResolution @Id("aggregate"), - fusionConfig: FusionConfig + fusionConfig: FusionConfig, + baseUri: BaseUri ) => - new IdResolutionRoutes(identities, aclCheck, idResolution)( + new IdResolutionRoutes(identities, aclCheck, idResolution, baseUri)( s, ordering, rcr, diff --git a/delta/plugins/elasticsearch/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/routes/IdResolutionRoutes.scala b/delta/plugins/elasticsearch/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/routes/IdResolutionRoutes.scala index b29fa9e6db..2cb4d2f74b 100644 --- a/delta/plugins/elasticsearch/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/routes/IdResolutionRoutes.scala +++ b/delta/plugins/elasticsearch/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/routes/IdResolutionRoutes.scala @@ -1,29 +1,34 @@ package ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.routes -import akka.http.scaladsl.model.Uri +import akka.http.scaladsl.model.{StatusCodes, Uri} import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.IdResolutionResponse.{MultipleResults, SingleResult} import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.query.ElasticSearchQueryError import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.{IdResolution, IdResolutionResponse} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution +import ch.epfl.bluebrain.nexus.delta.rdf.syntax.uriSyntax import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck import ch.epfl.bluebrain.nexus.delta.sdk.directives.AuthDirectives import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._ import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities +import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import monix.bio.{IO, UIO} import monix.execution.Scheduler class IdResolutionRoutes( identities: Identities, aclCheck: AclCheck, - idResolution: IdResolution + idResolution: IdResolution, + baseUri: BaseUri )(implicit s: Scheduler, jko: JsonKeyOrdering, rcr: RemoteContextResolution, fusionConfig: FusionConfig) extends AuthDirectives(identities, aclCheck) { - def routes: Route = + def routes: Route = concat(resolutionRoute, proxyRoute) + + private def resolutionRoute: Route = pathPrefix("resolve") { extractCaller { implicit caller => (get & iriSegment & pathEndOrSingleSlash) { iri => @@ -37,6 +42,19 @@ class IdResolutionRoutes( } } + private def proxyRoute: Route = + pathPrefix("resolve-proxy-pass") { + extractUnmatchedPath { path => + get { + val resourceId = fusionConfig.resolveBase / path + emitOrFusionRedirect( + fusionResolveUri(resourceId), + redirect(deltaResolveEndpoint(resourceId), StatusCodes.SeeOther) + ) + } + } + } + private def fusionUri( resolved: IO[ElasticSearchQueryError, IdResolutionResponse.Result] ): UIO[Uri] = @@ -47,4 +65,7 @@ class IdResolutionRoutes( } .onErrorHandleWith { _ => fusionLoginUri } + private def deltaResolveEndpoint(id: Uri): Uri = + baseUri.endpoint / "resolve" / id.toString + } diff --git a/delta/plugins/elasticsearch/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/routes/IdResolutionRoutesSuite.scala b/delta/plugins/elasticsearch/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/routes/IdResolutionRoutesSuite.scala index 4a3d2f6cf0..0d9cf90fd0 100644 --- a/delta/plugins/elasticsearch/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/routes/IdResolutionRoutesSuite.scala +++ b/delta/plugins/elasticsearch/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/routes/IdResolutionRoutesSuite.scala @@ -1,6 +1,7 @@ package ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.routes -import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.model.headers.Location +import akka.http.scaladsl.model.{HttpResponse, StatusCodes} import akka.http.scaladsl.server.Route import ch.epfl.bluebrain.nexus.delta.kernel.utils.UrlUtils import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.IdResolution @@ -43,7 +44,8 @@ class IdResolutionRoutesSuite extends ElasticSearchViewsRoutesFixtures { } private val idResolution = new IdResolution(dummyDefaultViewsQuery, fetchResource) - private val route = Route.seal(new IdResolutionRoutes(identities, aclCheck, idResolution).routes) + private val route = + Route.seal(new IdResolutionRoutes(identities, aclCheck, idResolution, baseUri).routes) "The IdResolution route" should { @@ -54,6 +56,21 @@ class IdResolutionRoutesSuite extends ElasticSearchViewsRoutesFixtures { } } + "redirect the proxy call to the resolve endpoint" in { + val segment = s"neurosciencegraph/data/$uuid" + val fullId = s"https://bbp.epfl.ch/$segment" + val expectedRedirection = s"$baseUri/resolve/${UrlUtils.encode(fullId)}".replace("%3A", ":") + + Get(s"/resolve-proxy-pass/$segment") ~> route ~> check { + response.status shouldEqual StatusCodes.SeeOther + response.locationHeader shouldEqual expectedRedirection + } + } + + } + + implicit class HeaderOps(response: HttpResponse) { + def locationHeader: String = response.header[Location].value.uri.toString() } } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectives.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectives.scala index 231fc1a0b6..31d1c20ffa 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectives.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectives.scala @@ -180,6 +180,10 @@ trait DeltaDirectives extends UriDirectives { def fusionLoginUri(implicit config: FusionConfig): UIO[Uri] = UIO.pure { config.base / "login" } + /** The URI of fusion's id resolution endpoint */ + def fusionResolveUri(id: Uri)(implicit config: FusionConfig): UIO[Uri] = + UIO.pure { config.base / "resolve" / id.toString } + /** Injects a `Vary: Accept,Accept-Encoding` into the response */ def varyAcceptHeaders: Directive0 = vary(Set(Accept.name, `Accept-Encoding`.name)) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/fusion/FusionConfig.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/fusion/FusionConfig.scala index dac98c1a22..e6c3e50e6e 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/fusion/FusionConfig.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/fusion/FusionConfig.scala @@ -11,8 +11,10 @@ import pureconfig.generic.semiauto.deriveReader * the base url of fusion * @param enableRedirects * enables redirections to Fusion if the `Accept` header is set to `text/html` + * @param resolveBase + * base to use to reconstruct resource identifiers in the resolve proxy pass */ -final case class FusionConfig(base: Uri, enableRedirects: Boolean) +final case class FusionConfig(base: Uri, enableRedirects: Boolean, resolveBase: Uri) object FusionConfig { implicit final val fusionConfigReader: ConfigReader[FusionConfig] = diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/ConfigFixtures.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/ConfigFixtures.scala index bad3229faa..ad20186094 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/ConfigFixtures.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/ConfigFixtures.scala @@ -33,7 +33,8 @@ trait ConfigFixtures { def httpClientConfig: HttpClientConfig = HttpClientConfig(RetryStrategyConfig.AlwaysGiveUp, HttpClientWorthRetry.never, false) - def fusionConfig: FusionConfig = FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true) + def fusionConfig: FusionConfig = + FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true, Uri("https://bbp.epfl.ch")) def deletionConfig: ProjectsConfig.DeletionConfig = ProjectsConfig.DeletionConfig( enabled = true, diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectivesSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectivesSpec.scala index 90c12dad0f..2cc879d666 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectivesSpec.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/DeltaDirectivesSpec.scala @@ -57,7 +57,8 @@ class DeltaDirectivesSpec List("@context", "@id", "@type", "reason", "details", "sourceId", "projectionId", "_total", "_results") ) - implicit private val f: FusionConfig = FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true) + implicit private val f: FusionConfig = + FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true, Uri("https://bbp.epfl.ch")) implicit val baseUri: BaseUri = BaseUri("http://localhost", Label.unsafe("v1")) diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala index 97aa1d58f2..e4a2d40c75 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala @@ -56,7 +56,8 @@ trait RouteFixtures { implicit val baseUri: BaseUri = BaseUri("http://localhost", Label.unsafe("v1")) implicit val paginationConfig: PaginationConfig = PaginationConfig(5, 10, 5) - implicit val f: FusionConfig = FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true) + implicit val f: FusionConfig = + FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true, Uri("https://bbp.epfl.ch")) implicit val s: Scheduler = Scheduler.global implicit val rejectionHandler: RejectionHandler = RdfRejectionHandler.apply implicit val exceptionHandler: ExceptionHandler = RdfExceptionHandler.apply diff --git a/docs/src/main/paradox/docs/delta/api/assets/id-resolution/proxy-request.sh b/docs/src/main/paradox/docs/delta/api/assets/id-resolution/proxy-request.sh new file mode 100644 index 0000000000..ac76f7267c --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/id-resolution/proxy-request.sh @@ -0,0 +1 @@ +curl "http://localhost:8080/v1/resolve-proxy-pass/nexus/data/identifier" \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/id-resolution/proxy-response-delta.html b/docs/src/main/paradox/docs/delta/api/assets/id-resolution/proxy-response-delta.html new file mode 100644 index 0000000000..538ed1d0a3 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/id-resolution/proxy-response-delta.html @@ -0,0 +1,3 @@ +303 See Other +The response to the request can be found under this URI using a +GET method. \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/id-resolution/proxy-response-fusion.html b/docs/src/main/paradox/docs/delta/api/assets/id-resolution/proxy-response-fusion.html new file mode 100644 index 0000000000..f890c2a3d4 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/id-resolution/proxy-response-fusion.html @@ -0,0 +1,3 @@ +303 See Other +The response to the request can be found under this URI using a +GET method. \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/id-resolution.md b/docs/src/main/paradox/docs/delta/api/id-resolution.md index 99315ee696..d592f6ca9e 100644 --- a/docs/src/main/paradox/docs/delta/api/id-resolution.md +++ b/docs/src/main/paradox/docs/delta/api/id-resolution.md @@ -37,3 +37,66 @@ Response (single resource) Response (multiple choices) : @@snip [response.json](assets/id-resolution/multiple-resolution-response.json) +### Fusion Redirection + +When querying the resolve endpoint, it is possible to add the `Accept: text/html` header in order for Nexus Delta to +redirect to the appropriate Nexus Fusion page. + +## Resolve (Proxy Pass) + +@@@ note { .warning } + +This endpoint is designed for the Nexus deployment at the Blue Brain Project and as such might not suit the needs of +other deployments. + +@@@ + +The @ref:[resolve](#resolve) endpoint offers resource resolution on the resources the caller has access to. As such, if +the client is a browser and does not have the ability to include an authorization header in the request, it is possible +to use the proxy pass version of the resolve endpoint which will lead the client to a Nexus Fusion authentication page. +This will allow to "inject" the user's token in a subsequent @ref:[resolve](#resolve) request made by Nexus Fusion. + +### Configuration + +* `app.fusion.base`: String - base URL for Nexus Fusion +* `app.fusion.enable-redirects`: Boolean - needs to be `true` in order for redirects to work (defaults to `false`) +* `app.fusion.resolve-base`: String - base URL to use when reconstructing the resource identifier in the + proxy pass endpoint + +### Redirection + +1. The client calls `/v1/resolve-proxy-pass/{segment}` +2. Nexus Delta reconstructs the resource identifier + * `{resourceId}` = `{resolveBase}/{segment}` +3. Nexus Delta redirects the client to... + * the `{fusionBaseUrl}/resolve/{resourceId}` Fusion endpoint if the `Accept: text/html` header is present + * the `/v1/resolve/{resourceId}` Delta endpoint otherwise + +The Nexus Fusion resolve page allows the user to authenticate (if they are not already authenticated) and will perform a +call to the Nexus Delta `/v1/resolve/{resourceId}` with the user's authentication token. + +All calls to the `/v1/resolve-proxy-pass` endpoint lead to +@link:[303 See Other responses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303). + +### Example + +The example below assumes that: + +* `{fusionBaseUrl}` = `http://localhost:8080/fusion` +* `{segment}` = `nexus/data/identifier` +* `{resolveBase}` = `https://example.com` + +Request +: @@snip [request.sh](assets/id-resolution/proxy-request.sh) + +Redirect (when `Acccept:text/html` is provided) +: @@snip [response.json](assets/id-resolution/proxy-response-fusion.html) + +Redirect (no `Accept:text/html` provided) +: @@snip [response.json](assets/id-resolution/proxy-response-delta.html) + +#### Remark + +In your networking setup, if a proxy pass is enabled to map `https://example.com/nexus/data/*` +to `https://localhost:8080/v1/resolve-proxy-pass/nexus/data/*`, the proxy pass allows to de facto resolve resource with +identifier of the type `https://example.com/nexus/data/*` by simply querying their `@id`. \ No newline at end of file diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/IdResolutionSpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/IdResolutionSpec.scala index 193d44bec9..779102370e 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/IdResolutionSpec.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/IdResolutionSpec.scala @@ -10,6 +10,7 @@ import ch.epfl.bluebrain.nexus.tests.BaseSpec import ch.epfl.bluebrain.nexus.tests.Identity.listings.{Alice, Bob} import ch.epfl.bluebrain.nexus.tests.iam.types.Permission.Organizations import io.circe.Json +import org.scalatest.Assertion class IdResolutionSpec extends BaseSpec { @@ -36,6 +37,11 @@ class IdResolutionSpec extends BaseSpec { private val uniqueResourcePayload = resource(uniqueId) private val reusedResourcePayload = resource(reusedId) + private val neurosciencegraphSegment = "neurosciencegraph/data/segment" + private val proxyIdBase = "http://localhost:8081" + private val neurosciencegraphId = s"$proxyIdBase/$neurosciencegraphSegment" + private val encodedNeurosciencegraphId = UrlUtils.encode(neurosciencegraphId) + private val unauthorizedAccessErrorPayload = jsonContentOf("iam/errors/unauthorized-access.json") @@ -93,30 +99,50 @@ class IdResolutionSpec extends BaseSpec { } "redirect to fusion login when if text/html accept header is present (no results)" in { - val expectedRedirectUrl = "https://bbp.epfl.ch/nexus/web/login" + val fusionLoginPage = "https://bbp.epfl.ch/nexus/web/login" deltaClient.get[String]("/resolve/unknownId", Bob, acceptTextHtml) { (_, response) => - response.status shouldEqual StatusCodes.SeeOther - locationHeaderOf(response) shouldEqual expectedRedirectUrl + response isRedirectTo fusionLoginPage }(PredefinedFromEntityUnmarshallers.stringUnmarshaller) } "redirect to fusion resource page if text/html accept header is present (single result)" in { deltaClient.get[String](s"/resolve/$encodedUniqueId", Bob, acceptTextHtml) { (_, response) => - response.status shouldEqual StatusCodes.SeeOther - locationHeaderOf(response) shouldEqual fusionResourcePageFor(encodedUniqueId) + response isRedirectTo fusionResourcePageFor(encodedUniqueId) }(PredefinedFromEntityUnmarshallers.stringUnmarshaller) } "redirect to fusion resource selection page if text/html accept header is present (multiple result)" in { pending } + "redirect to delta resolve if the request comes to the proxy endpoint" in { + deltaClient.get[String](s"/resolve-proxy-pass/$neurosciencegraphSegment", Bob) { (_, response) => + response isRedirectTo deltaResolveEndpoint(encodedNeurosciencegraphId) + }(PredefinedFromEntityUnmarshallers.stringUnmarshaller) + } + + "redirect to fusion resolve if the request comes to the proxy endpoint with text/html accept header is present" in { + deltaClient.get[String](s"/resolve-proxy-pass/$neurosciencegraphSegment", Bob, acceptTextHtml) { (_, response) => + response isRedirectTo fusionResolveEndpoint(encodedNeurosciencegraphId) + }(PredefinedFromEntityUnmarshallers.stringUnmarshaller) + } + } + implicit class HttpResponseOps(response: HttpResponse) { + def isRedirectTo(uri: String): Assertion = { + response.status shouldEqual StatusCodes.SeeOther + locationHeaderOf(response) shouldEqual uri + } + } private def locationHeaderOf(response: HttpResponse) = response.header[Location].value.uri.toString() private def acceptTextHtml = List(Accept(MediaRange.One(`text/html`, 1f))) private def fusionResourcePageFor(encodedId: String) = s"https://bbp.epfl.ch/nexus/web/$ref11/resources/$encodedId".replace("%3A", ":") + private def fusionResolveEndpoint(encodedId: String) = + s"https://bbp.epfl.ch/nexus/web/resolve/$encodedId".replace("%3A", ":") + private def deltaResolveEndpoint(encodedId: String) = + s"http://delta:8080/v1/resolve/$encodedId".replace("%3A", ":") }