diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml
index c8cf55f..1242b9a 100644
--- a/.github/workflows/build-docker-image.yml
+++ b/.github/workflows/build-docker-image.yml
@@ -7,7 +7,7 @@ on:
env:
REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository }}-snapshot
+ IMAGE_NAME: ${{ github.repository }}-oidc
jobs:
build:
diff --git a/.github/workflows/maven-build.yml b/.github/workflows/maven-build.yml
index fec49ef..c806981 100644
--- a/.github/workflows/maven-build.yml
+++ b/.github/workflows/maven-build.yml
@@ -3,7 +3,7 @@ name: Beacon Network Maven Build
on:
push:
branches:
- - dev
+ - oidc
jobs:
build:
diff --git a/pom.xml b/pom.xml
index 68e20d3..1929fa5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -26,7 +26,7 @@
4.0.0
es.bsc.inb.ga4gh
beacon-network-v2
- 0.0.12-SNAPSHOT
+ 0.0.12-oidc
war
diff --git a/src/main/java/es/bsc/inb/ga4gh/beacon/network/config/NetworkConfiguration.java b/src/main/java/es/bsc/inb/ga4gh/beacon/network/config/NetworkConfiguration.java
index 787a407..6c585ab 100644
--- a/src/main/java/es/bsc/inb/ga4gh/beacon/network/config/NetworkConfiguration.java
+++ b/src/main/java/es/bsc/inb/ga4gh/beacon/network/config/NetworkConfiguration.java
@@ -25,6 +25,7 @@
package es.bsc.inb.ga4gh.beacon.network.config;
+import es.bsc.inb.ga4gh.beacon.network.model.OauthProtectedResource;
import es.bsc.inb.ga4gh.beacon.framework.model.v200.configuration.ServiceConfiguration;
import es.bsc.inb.ga4gh.beacon.framework.model.v200.responses.BeaconEntryTypesResponse;
import es.bsc.inb.ga4gh.beacon.framework.model.v200.responses.BeaconFilteringTermsResponse;
@@ -90,6 +91,12 @@ public class NetworkConfiguration {
*/
private Map endpoints;
+ /**
+ * Map of beacons' authentication servers
+ * where key is a beacon's endpoint (e.g. 'https://beacons.bsc.es/beacon/v2.0.0/')
+ */
+ private Map protected_resources;
+
private Map> metadata;
private Map> errors;
@@ -107,6 +114,7 @@ public class NetworkConfiguration {
@PostConstruct
public void init() {
endpoints = new ConcurrentHashMap();
+ protected_resources = new ConcurrentHashMap();
metadata = new ConcurrentHashMap();
errors = new ConcurrentHashMap();
hashes = new ConcurrentHashMap();
@@ -214,6 +222,13 @@ private void updateBeacon(String endpoint) {
}
errors.put(endpoint, err);
}
+
+ final OauthProtectedResource resource = loadOauthProtectedResource(endpoint);
+ if (resource == null) {
+ protected_resources.remove(endpoint);
+ } else {
+ protected_resources.put(endpoint, resource);
+ }
}
}
@@ -323,6 +338,10 @@ public Map getEndpoints() {
return endpoints;
}
+ public Map getProtectedResources() {
+ return protected_resources;
+ }
+
/**
* Get the metadata JSON Schema parsing errors.
*
@@ -351,6 +370,28 @@ private String getBeaconId(String endpoint) {
return null;
}
+ /**
+ * Load oauth-protected-resource metadata, if provided.
+ *
+ * @param endpoint
+ * @return
+ */
+ private OauthProtectedResource loadOauthProtectedResource(String endpoint) {
+ try {
+ final List err = new ArrayList();
+ final String json = validator.loadMetadata(endpoint + "/.well-known/oauth-protected-resource", new ValidationErrorsCollector(err));
+ if (err.isEmpty()) {
+ return validator.parseMetadata(json, OauthProtectedResource.class);
+ }
+ } catch(Exception ex) {
+ Logger.getLogger(NetworkConfiguration.class.getName()).log(
+ Level.SEVERE, "error loading from {0} {1}",
+ new Object[]{endpoint, ex.getMessage()});
+ }
+ return null;
+
+ }
+
/**
* Load filtering terms by the endpoint.
*
@@ -374,5 +415,5 @@ public BeaconFilteringTermsResponse loadFilteringTerms(String endpoint) {
new Object[]{endpoint, ex.getMessage()});
}
return null;
- }
+ }
}
diff --git a/src/main/java/es/bsc/inb/ga4gh/beacon/network/engine/BeaconNetworkAggregator.java b/src/main/java/es/bsc/inb/ga4gh/beacon/network/engine/BeaconNetworkAggregator.java
index 77a44ea..e8fe165 100644
--- a/src/main/java/es/bsc/inb/ga4gh/beacon/network/engine/BeaconNetworkAggregator.java
+++ b/src/main/java/es/bsc/inb/ga4gh/beacon/network/engine/BeaconNetworkAggregator.java
@@ -77,6 +77,9 @@ public class BeaconNetworkAggregator {
@Inject
private BeaconNetworkRequestAnalyzer requestAnalyzer;
+ @Inject
+ private BeaconNetworkTokenExchanger tokenExchanger;
+
@Inject
private BeaconEndpointsMatcher matcher;
@@ -127,7 +130,7 @@ public Response aggregate(HttpServletRequest request) {
}
final UUID xid = UUID.randomUUID();
-
+
final List> invocations = new ArrayList();
Map> matched_endpoints = matcher.match(request);
@@ -150,7 +153,7 @@ public Response aggregate(HttpServletRequest request) {
} else {
final String err_message =
String.format("request timeout '%s'", processor.template);
-
+
log(req, 408, err_message);
}
return res;
@@ -206,7 +209,7 @@ private Builder getInvocation(String endpoint, HttpServletRequest request) {
final Enumeration authorization = request.getHeaders(HttpHeaders.AUTHORIZATION);
if (authorization != null && authorization.hasMoreElements()) {
- Collections.list(authorization).stream()
+ tokenExchanger.exchange(endpoint, Collections.list(authorization)).stream()
.forEach(h -> builder.header(HttpHeaders.AUTHORIZATION, h));
}
@@ -220,7 +223,7 @@ private void log(HttpResponse response) {
final BeaconError err = error.getError();
if (err != null) {
message = err.getErrorMessage();
- }
+}
}
log(response.request(), response.statusCode(), message);
diff --git a/src/main/java/es/bsc/inb/ga4gh/beacon/network/engine/BeaconNetworkTokenExchanger.java b/src/main/java/es/bsc/inb/ga4gh/beacon/network/engine/BeaconNetworkTokenExchanger.java
new file mode 100644
index 0000000..5017bf5
--- /dev/null
+++ b/src/main/java/es/bsc/inb/ga4gh/beacon/network/engine/BeaconNetworkTokenExchanger.java
@@ -0,0 +1,235 @@
+/**
+ * *****************************************************************************
+ * Copyright (C) 2024 ELIXIR ES, Spanish National Bioinformatics Institute (INB)
+ * and Barcelona Supercomputing Center (BSC)
+ *
+ * Modifications to the initial code base are copyright of their respective
+ * authors, or their employers as appropriate.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301 USA
+ * *****************************************************************************
+ */
+
+package es.bsc.inb.ga4gh.beacon.network.engine;
+
+import es.bsc.inb.ga4gh.beacon.network.config.NetworkConfiguration;
+import es.bsc.inb.ga4gh.beacon.network.model.AccessTokenResponse;
+import es.bsc.inb.ga4gh.beacon.network.model.OauthProtectedResource;
+import es.bsc.inb.ga4gh.beacon.network.model.OidcConfigurationProvider;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.json.Json;
+import jakarta.json.JsonArray;
+import jakarta.json.JsonObject;
+import jakarta.json.JsonString;
+import jakarta.json.bind.Jsonb;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.UriBuilder;
+import java.io.ByteArrayInputStream;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpRequest.BodyPublishers;
+import java.net.http.HttpRequest.Builder;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * @author Dmitry Repchevsky
+ */
+
+@ApplicationScoped
+public class BeaconNetworkTokenExchanger {
+
+ @Inject
+ private NetworkConfiguration network_configuration;
+
+ @Inject
+ private Jsonb jsonb;
+
+ private HttpClient http_client;
+ private Map configuration_providers;
+
+ @PostConstruct
+ public void init() {
+ http_client = HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_2)
+ .followRedirects(HttpClient.Redirect.ALWAYS)
+ .connectTimeout(Duration.ofSeconds(30))
+ .build();
+
+ configuration_providers = new ConcurrentHashMap();
+ }
+
+ public List exchange(String endpoint, List headers) {
+ return headers.stream().map(h -> exchangeHeader(endpoint, h)).toList();
+ }
+
+ private String exchangeHeader(String endpoint, String header) {
+ if (header != null && header.startsWith("Bearer ")) {
+ final String token = exchangeToken(endpoint, header.substring(7));
+ if (token != null) {
+ return "Bearer " + token;
+ }
+ }
+ return header;
+ }
+
+ private String exchangeToken(String endpoint, String token) {
+ final OauthProtectedResource resource = network_configuration.getProtectedResources().get(endpoint);
+ if (resource != null) {
+ final String client_id = resource.getClientId();
+ final List authorization_servers = resource.getAuthorizationServers();
+ if (client_id != null && authorization_servers != null) {
+ final String[] token_parts = token.split("\\.");
+ if (token_parts.length == 3) {
+ final JsonObject payload = decode(token_parts[1]);
+ if (payload != null) {
+ final String issuer = payload.getString("iss", null);
+
+ final List audiences;
+ final String audience = payload.getString("aud", null);
+ if (audience != null) {
+ audiences = List.of(audience);
+ } else {
+ final JsonArray aud = payload.getJsonArray("aud");
+ audiences = aud != null
+ ? aud.getValuesAs(JsonString::getString)
+ : null;
+ }
+ if (!authorization_servers.contains(issuer) ||
+ (audiences != null && !audiences.contains(client_id))) {
+ // need exchange
+ final List providers = getWellKnownProviders(authorization_servers);
+ if (providers != null) {
+ for (OidcConfigurationProvider provider : providers) {
+ final Builder builder = createTokenExchangeRequest(provider, client_id, token);
+ try {
+ final HttpResponse response = http_client.send(builder.build(),
+ BodyHandlers.ofString(StandardCharsets.UTF_8));
+ if (response != null && response.statusCode() < 300) {
+ final String body = response.body();
+ if (body != null) {
+ final AccessTokenResponse atResponse =
+ jsonb.fromJson(body, AccessTokenResponse.class);
+
+ final String accessToken = atResponse.getAccessToken();
+ if (accessToken != null) {
+ return accessToken;
+ }
+ }
+ }
+ } catch (Exception ex) {
+ Logger.getLogger(BeaconNetworkTokenExchanger.class.getName()).log(
+ Level.INFO, ex.getMessage());
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ private List getWellKnownProviders(List authorization_servers) {
+
+ final List providers = new ArrayList();
+
+ final List>> invocations = new ArrayList();
+ for (String authorization_server : authorization_servers) {
+ final OidcConfigurationProvider provider = configuration_providers.get(authorization_server);
+ if (provider != null) {
+ providers.add(provider);
+ } else {
+ final Builder builder = createWellKnownProviderRequest(authorization_server);
+ final CompletableFuture> future =
+ http_client.sendAsync(builder.build(), BodyHandlers.ofString(StandardCharsets.UTF_8));
+ invocations.add(future);
+ }
+ }
+ for (CompletableFuture> invocation : invocations) {
+ try {
+ final HttpResponse response = invocation.get(30, TimeUnit.SECONDS);
+ if (response != null && response.statusCode() < 300) {
+ final String body = response.body();
+ if (body != null) {
+ final OidcConfigurationProvider provider =
+ jsonb.fromJson(body, OidcConfigurationProvider.class);
+ providers.add(provider);
+ }
+ }
+ } catch (Exception ex) {
+ Logger.getLogger(BeaconNetworkTokenExchanger.class.getName()).log(
+ Level.INFO, ex.getMessage());
+ }
+ }
+ return providers;
+ }
+
+ private Builder createWellKnownProviderRequest(String authorization_server) {
+ return HttpRequest.newBuilder(UriBuilder.fromUri(authorization_server)
+ .path(".well-known/openid-configuration").build())
+ .header(HttpHeaders.USER_AGENT, "BN/2.0.0")
+ .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON);
+ }
+
+ private Builder createTokenExchangeRequest(OidcConfigurationProvider provider,
+ String client_id, String token) {
+
+ final StringBuilder data = new StringBuilder();
+
+ data.append("client_id").append('=').append(client_id)
+ .append("&subject_token").append('=').append(token)
+ .append("&grant_type").append('=')
+ .append(URLEncoder.encode("urn:ietf:params:oauth:grant-type:token-exchange", StandardCharsets.UTF_8))
+ .append("&subject_token_type").append('=')
+ .append(URLEncoder.encode("urn:ietf:params:oauth:token-type:jwt", StandardCharsets.UTF_8))
+ .append("&requested_token_type").append('=')
+ .append(URLEncoder.encode("urn:ietf:params:oauth:token-type:access_token", StandardCharsets.UTF_8));
+
+ return HttpRequest.newBuilder(UriBuilder.fromUri(provider.getTokenEndpoint())
+ .build())
+ .header(HttpHeaders.USER_AGENT, "BN/2.0.0")
+ .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON)
+ .POST(BodyPublishers.ofString(data.toString(), StandardCharsets.UTF_8));
+ }
+
+ private JsonObject decode(String base64) {
+ final Base64.Decoder decoder = Base64.getDecoder();
+ try {
+ final byte[] b = decoder.decode(base64);
+ return Json.createReader(new ByteArrayInputStream(b)).readObject();
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/es/bsc/inb/ga4gh/beacon/network/model/AccessTokenResponse.java b/src/main/java/es/bsc/inb/ga4gh/beacon/network/model/AccessTokenResponse.java
new file mode 100644
index 0000000..80dac75
--- /dev/null
+++ b/src/main/java/es/bsc/inb/ga4gh/beacon/network/model/AccessTokenResponse.java
@@ -0,0 +1,93 @@
+/**
+ * *****************************************************************************
+ * Copyright (C) 2024 ELIXIR ES, Spanish National Bioinformatics Institute (INB)
+ * and Barcelona Supercomputing Center (BSC)
+ *
+ * Modifications to the initial code base are copyright of their respective
+ * authors, or their employers as appropriate.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301 USA
+ * *****************************************************************************
+ */
+
+package es.bsc.inb.ga4gh.beacon.network.model;
+
+import jakarta.json.bind.annotation.JsonbProperty;
+
+/**
+ * The model for the OpenID Access Token Response (RFC6749 5.1)
+ * that is returned by successful Token Exchange (RFC8693) operation.
+ *
+ * @author Dmitry Repchevsky
+ */
+
+public class AccessTokenResponse {
+
+ private String token_type;
+ private String access_token;
+ private String refresh_token;
+ private String expires_in;
+ private String scope;
+
+ @JsonbProperty("token_type")
+ public String getTokenType() {
+ return token_type;
+ }
+
+ @JsonbProperty("token_type")
+ public void setTokenType(String token_type) {
+ this.token_type = token_type;
+ }
+
+ @JsonbProperty("access_token")
+ public String getAccessToken() {
+ return access_token;
+ }
+
+ @JsonbProperty("access_token")
+ public void setAccessToken(String access_token) {
+ this.access_token = access_token;
+ }
+
+ @JsonbProperty("refresh_token")
+ public String getRefreshToken() {
+ return refresh_token;
+ }
+
+ @JsonbProperty("refresh_token")
+ public void setRefreshToken(String refresh_token) {
+ this.refresh_token = refresh_token;
+ }
+
+ @JsonbProperty("expires_in")
+ public String getExpiresIn() {
+ return expires_in;
+ }
+
+ @JsonbProperty("expires_in")
+ public void setExpiresIn(String expires_in) {
+ this.expires_in = expires_in;
+ }
+
+ public String getScope() {
+ return scope;
+ }
+
+ public void setScope(String scope) {
+ this.scope = scope;
+ }
+
+}
diff --git a/src/main/java/es/bsc/inb/ga4gh/beacon/network/model/OauthProtectedResource.java b/src/main/java/es/bsc/inb/ga4gh/beacon/network/model/OauthProtectedResource.java
new file mode 100644
index 0000000..dd966c2
--- /dev/null
+++ b/src/main/java/es/bsc/inb/ga4gh/beacon/network/model/OauthProtectedResource.java
@@ -0,0 +1,68 @@
+/**
+ * *****************************************************************************
+ * Copyright (C) 2024 ELIXIR ES, Spanish National Bioinformatics Institute (INB)
+ * and Barcelona Supercomputing Center (BSC)
+ *
+ * Modifications to the initial code base are copyright of their respective
+ * authors, or their employers as appropriate.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301 USA
+ * *****************************************************************************
+ */
+
+package es.bsc.inb.ga4gh.beacon.network.model;
+
+import jakarta.json.bind.annotation.JsonbProperty;
+import java.util.List;
+
+/**
+ * @author Dmitry Repchevsky
+ */
+
+public class OauthProtectedResource {
+
+ private String resource;
+ private String client_id;
+ private List authorization_servers;
+
+ public String getResource() {
+ return resource;
+ }
+
+ public void setResource(String resource) {
+ this.resource = resource;
+ }
+
+ @JsonbProperty("client_id")
+ public String getClientId() {
+ return client_id;
+ }
+
+ @JsonbProperty("client_id")
+ public void setClientId(String client_id) {
+ this.client_id = client_id;
+ }
+
+ @JsonbProperty("authorization_servers")
+ public List getAuthorizationServers() {
+ return authorization_servers;
+ }
+
+ @JsonbProperty("authorization_servers")
+ public void setAuthorizationServers(List authorization_servers) {
+ this.authorization_servers = authorization_servers;
+ }
+}
diff --git a/src/main/java/es/bsc/inb/ga4gh/beacon/network/model/OidcConfigurationProvider.java b/src/main/java/es/bsc/inb/ga4gh/beacon/network/model/OidcConfigurationProvider.java
new file mode 100644
index 0000000..671ab6f
--- /dev/null
+++ b/src/main/java/es/bsc/inb/ga4gh/beacon/network/model/OidcConfigurationProvider.java
@@ -0,0 +1,58 @@
+/**
+ * *****************************************************************************
+ * Copyright (C) 2024 ELIXIR ES, Spanish National Bioinformatics Institute (INB)
+ * and Barcelona Supercomputing Center (BSC)
+ *
+ * Modifications to the initial code base are copyright of their respective
+ * authors, or their employers as appropriate.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301 USA
+ * *****************************************************************************
+ */
+
+package es.bsc.inb.ga4gh.beacon.network.model;
+
+import jakarta.json.bind.annotation.JsonbProperty;
+
+/**
+ * Minimal model for the OpenID Provider Configuration Response object.
+ *
+ * @author Dmitry Repchevsky
+ */
+
+public class OidcConfigurationProvider {
+
+ private String issuer;
+ private String token_endpoint;
+
+ public String getIssuer() {
+ return issuer;
+ }
+
+ public void setIssuer(String issuer) {
+ this.issuer = issuer;
+ }
+
+ @JsonbProperty("token_endpoint")
+ public String getTokenEndpoint() {
+ return token_endpoint;
+ }
+
+ @JsonbProperty("token_endpoint")
+ public void setTokenEndpoint(String token_endpoint) {
+ this.token_endpoint = token_endpoint;
+ }
+}