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; + } +}