From 9d78a559a60c7eb52eb5a1a04717b224cf9d8918 Mon Sep 17 00:00:00 2001 From: harrykodden Date: Thu, 13 Aug 2026 16:26:08 +0200 Subject: [PATCH] use OIDC Provider Discovery endpoint Signed-off-by: Harry Kodden --- .../src/main/markdown/config-frontend.md | 28 +++ .../resources/auth/OidcCodeFlowCallback.java | 13 +- .../dcache/restful/util/OidcDiscovery.java | 141 +++++++++++++++ .../util/OidcDiscoveryConfigDecorator.java | 108 ++++++++++++ .../org/dcache/frontend/frontend.xml | 14 +- .../restful/util/OidcDiscoveryTest.java | 162 ++++++++++++++++++ skel/share/defaults/frontend.properties | 35 +++- 7 files changed, 494 insertions(+), 7 deletions(-) create mode 100644 modules/dcache-frontend/src/main/java/org/dcache/restful/util/OidcDiscovery.java create mode 100644 modules/dcache-frontend/src/main/java/org/dcache/restful/util/OidcDiscoveryConfigDecorator.java create mode 100644 modules/dcache-frontend/src/test/java/org/dcache/restful/util/OidcDiscoveryTest.java diff --git a/docs/TheBook/src/main/markdown/config-frontend.md b/docs/TheBook/src/main/markdown/config-frontend.md index efd7fd6b9b2..fef13cfa59b 100644 --- a/docs/TheBook/src/main/markdown/config-frontend.md +++ b/docs/TheBook/src/main/markdown/config-frontend.md @@ -177,6 +177,34 @@ In order to get all the restores (stages) on a given pool, the REST path must be used. +## OpenID Connect for dCache View + +dCache View can log users in with the OpenID Connect authorization +code flow. Configure the client on the frontend cell: + +```ini +[dCacheDomain/frontend] +frontend.authn.oidc.issuer = https://op.example.org +frontend.authn.oidc.client-id = +frontend.authn.oidc.client-secret = +frontend.static!dcache-view.oidc-provider-name-list = ExampleOP +frontend.static!dcache-view.oidc-client-id-list = +frontend.static!dcache-view.oidc-authz-redirect-url = https://view.example.org:3880/api/v1/auth/callback +``` + +The frontend then fetches `{issuer}/.well-known/openid-configuration` +and takes `authorization_endpoint` (shown on the View login page) and +`token_endpoint` (used to exchange the authorization code). You can +still set `frontend.static!dcache-view.oidc-authz-endpoint-list` and +`frontend.authn.oidc.token-url` explicitly; those values override +discovery. + +For more than one provider, give space-separated issuer URLs in +`frontend.static!dcache-view.oidc-issuer-list` in the same order as +the name and client-id lists. The code-flow token exchange still uses +a single token endpoint (`frontend.authn.oidc.issuer` / +`frontend.authn.oidc.token-url`). + ##### RESTful API for QoS transitions The RESTful commands now communicate with the [QoS Engine](config-qos-engine.md) diff --git a/modules/dcache-frontend/src/main/java/org/dcache/restful/resources/auth/OidcCodeFlowCallback.java b/modules/dcache-frontend/src/main/java/org/dcache/restful/resources/auth/OidcCodeFlowCallback.java index 7d6f4b1775a..d3f2a697ae0 100644 --- a/modules/dcache-frontend/src/main/java/org/dcache/restful/resources/auth/OidcCodeFlowCallback.java +++ b/modules/dcache-frontend/src/main/java/org/dcache/restful/resources/auth/OidcCodeFlowCallback.java @@ -65,6 +65,7 @@ import org.dcache.auth.attributes.Restrictions; import org.dcache.auth.attributes.RootDirectory; import org.dcache.restful.providers.UserAttributes; +import org.dcache.restful.util.OidcDiscovery; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -81,6 +82,8 @@ public class OidcCodeFlowCallback { public String oidcClientId; public String oidcClientSecret; public String oidcTokenUrl; + public String oidcIssuer; + private OidcDiscovery oidcDiscovery; private LoginStrategy _loginStrategy; private Restriction _doorRestriction; @@ -99,6 +102,14 @@ public void setTokenUrl(String oidcTokenUrl) { this.oidcTokenUrl = oidcTokenUrl; } + public void setIssuer(String oidcIssuer) { + this.oidcIssuer = oidcIssuer; + } + + public void setOidcDiscovery(OidcDiscovery oidcDiscovery) { + this.oidcDiscovery = oidcDiscovery; + } + public void setLoginStrategy(LoginStrategy loginStrategy) { _loginStrategy = loginStrategy; } @@ -133,7 +144,7 @@ public Response callback(@QueryParam("code") String code, @Context HttpServletRe String body = "&code=" + code + "&grant_type=authorization_code" + "&redirect_uri=" + host+request.getRequestURI(); - URL url = new URL(oidcTokenUrl); + URL url = new URL(oidcDiscovery.resolveTokenEndpoint(oidcIssuer, oidcTokenUrl)); String basicAuth = Base64.getEncoder().encodeToString( (oidcClientId + ":" + oidcClientSecret).getBytes( diff --git a/modules/dcache-frontend/src/main/java/org/dcache/restful/util/OidcDiscovery.java b/modules/dcache-frontend/src/main/java/org/dcache/restful/util/OidcDiscovery.java new file mode 100644 index 00000000000..f21b8193b80 --- /dev/null +++ b/modules/dcache-frontend/src/main/java/org/dcache/restful/util/OidcDiscovery.java @@ -0,0 +1,141 @@ +/* + * dCache - http://www.dcache.org/ + * + * Copyright (C) 2026 Deutsches Elektronen-Synchrotron + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.dcache.restful.util; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.concurrent.ConcurrentHashMap; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Fetches and caches an OpenID Provider's discovery document + * ({@code /.well-known/openid-configuration}) as defined by + * OIDC + * Discovery. + *

+ * Used by the frontend to obtain {@code authorization_endpoint} and {@code token_endpoint} + * when those URLs are not configured explicitly. + */ +public class OidcDiscovery { + + private static final Logger LOGGER = LoggerFactory.getLogger(OidcDiscovery.class); + + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 30_000; + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + /** + * Build the discovery URL from an issuer, matching gPlazma's {@code IdentityProvider} + * construction: {@code {issuer}/.well-known/openid-configuration}, including issuers + * that have a path component. + */ + public static URI configurationEndpoint(URI issuer) { + String path = issuer.getPath(); + if (path == null) { + path = ""; + } + return issuer.resolve(withTrailingSlash(path) + ".well-known/openid-configuration"); + } + + private static String withTrailingSlash(String path) { + return path.endsWith("/") ? path : (path + "/"); + } + + /** + * Return {@code configuredTokenUrl} when it is non-blank; otherwise the + * {@code token_endpoint} from the issuer's discovery document. + */ + public String resolveTokenEndpoint(String issuer, String configuredTokenUrl) + throws IOException { + if (hasText(configuredTokenUrl)) { + return configuredTokenUrl.trim(); + } + if (!hasText(issuer)) { + throw new IOException( + "frontend.authn.oidc.token-url is empty and frontend.authn.oidc.issuer is not set"); + } + return tokenEndpoint(URI.create(issuer.trim())); + } + + public String authorizationEndpoint(URI issuer) throws IOException { + return requiredEndpoint(fetchDocument(issuer), "authorization_endpoint", issuer); + } + + public String tokenEndpoint(URI issuer) throws IOException { + return requiredEndpoint(fetchDocument(issuer), "token_endpoint", issuer); + } + + public JSONObject fetchDocument(URI issuer) throws IOException { + URI configuration = configurationEndpoint(issuer); + JSONObject cached = cache.get(configuration); + if (cached != null) { + return cached; + } + JSONObject document = download(configuration); + cache.put(configuration, document); + LOGGER.info("Loaded OIDC discovery document for {}", issuer); + return document; + } + + private JSONObject download(URI configuration) throws IOException { + URL url = configuration.toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestProperty("Accept", "application/json"); + try { + int status = conn.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + throw new IOException("OIDC discovery failed for " + configuration + + ": HTTP " + status); + } + try (InputStream in = conn.getInputStream()) { + String body = new String(in.readAllBytes(), UTF_8); + return new JSONObject(body); + } + } finally { + conn.disconnect(); + } + } + + private static String requiredEndpoint(JSONObject document, String field, URI issuer) + throws IOException { + if (!document.has(field) || document.isNull(field)) { + throw new IOException("OIDC discovery document for " + issuer + " has no " + field); + } + String value = document.getString(field); + if (!hasText(value)) { + throw new IOException("OIDC discovery document for " + issuer + " has empty " + field); + } + return value; + } + + static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/modules/dcache-frontend/src/main/java/org/dcache/restful/util/OidcDiscoveryConfigDecorator.java b/modules/dcache-frontend/src/main/java/org/dcache/restful/util/OidcDiscoveryConfigDecorator.java new file mode 100644 index 00000000000..eb6faf582d9 --- /dev/null +++ b/modules/dcache-frontend/src/main/java/org/dcache/restful/util/OidcDiscoveryConfigDecorator.java @@ -0,0 +1,108 @@ +/* + * dCache - http://www.dcache.org/ + * + * Copyright (C) 2026 Deutsches Elektronen-Synchrotron + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.dcache.restful.util; + +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableMap; +import java.net.URI; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.FactoryBean; + +/** + * Fills {@code dcache-view.oidc-authz-endpoint-list} from OIDC discovery when the admin + * did not set it. Issuers are taken from {@code dcache-view.oidc-issuer-list} when that + * is set, otherwise from {@code frontend.authn.oidc.issuer}. + */ +public class OidcDiscoveryConfigDecorator implements FactoryBean> { + + private static final Logger LOGGER = LoggerFactory.getLogger(OidcDiscoveryConfigDecorator.class); + + static final String AUTHZ_ENDPOINT_LIST = "dcache-view.oidc-authz-endpoint-list"; + static final String ISSUER_LIST = "dcache-view.oidc-issuer-list"; + + private Map delegate; + private String issuer; + private OidcDiscovery oidcDiscovery; + + public void setDelegate(Map delegate) { + this.delegate = delegate; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + public void setOidcDiscovery(OidcDiscovery oidcDiscovery) { + this.oidcDiscovery = oidcDiscovery; + } + + @Override + public Map getObject() { + return enrich(delegate); + } + + Map enrich(Map data) { + if (data == null) { + return ImmutableMap.of(); + } + if (OidcDiscovery.hasText(data.get(AUTHZ_ENDPOINT_LIST))) { + return data; + } + List issuers = issuersFrom(data); + if (issuers.isEmpty()) { + return data; + } + List endpoints = new ArrayList<>(); + for (String iss : issuers) { + try { + endpoints.add(oidcDiscovery.authorizationEndpoint(URI.create(iss))); + } catch (Exception e) { + LOGGER.warn("Failed to discover authorization_endpoint for {}: {}", iss, + e.toString()); + } + } + if (endpoints.isEmpty()) { + return data; + } + Map copy = new LinkedHashMap<>(data); + copy.put(AUTHZ_ENDPOINT_LIST, String.join(" ", endpoints)); + return ImmutableMap.copyOf(copy); + } + + private List issuersFrom(Map data) { + String listed = data.get(ISSUER_LIST); + if (OidcDiscovery.hasText(listed)) { + return Splitter.on(' ').omitEmptyStrings().trimResults().splitToList(listed); + } + if (OidcDiscovery.hasText(issuer)) { + return List.of(issuer.trim()); + } + return List.of(); + } + + @Override + public Class getObjectType() { + return Map.class; + } +} diff --git a/modules/dcache-frontend/src/main/resources/org/dcache/frontend/frontend.xml b/modules/dcache-frontend/src/main/resources/org/dcache/frontend/frontend.xml index 509c9fa5505..e4ef27a5931 100644 --- a/modules/dcache-frontend/src/main/resources/org/dcache/frontend/frontend.xml +++ b/modules/dcache-frontend/src/main/resources/org/dcache/frontend/frontend.xml @@ -425,8 +425,14 @@ Provide dCache configuration as JSON or JavaScript - - + + + + + + + + @@ -704,10 +710,14 @@ + + + + diff --git a/modules/dcache-frontend/src/test/java/org/dcache/restful/util/OidcDiscoveryTest.java b/modules/dcache-frontend/src/test/java/org/dcache/restful/util/OidcDiscoveryTest.java new file mode 100644 index 00000000000..a0c6302d1f5 --- /dev/null +++ b/modules/dcache-frontend/src/test/java/org/dcache/restful/util/OidcDiscoveryTest.java @@ -0,0 +1,162 @@ +/* + * dCache - http://www.dcache.org/ + * + * Copyright (C) 2026 Deutsches Elektronen-Synchrotron + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.dcache.restful.util; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class OidcDiscoveryTest { + + private HttpServer server; + private URI issuer; + private OidcDiscovery discovery; + private final AtomicInteger discoveryHits = new AtomicInteger(); + private String discoveryBody = + "{\"authorization_endpoint\":\"https://op.example/authorize\"," + + "\"token_endpoint\":\"https://op.example/token\"}"; + + @Before + public void setup() throws Exception { + discoveryHits.set(0); + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/.well-known/openid-configuration", exchange -> { + discoveryHits.incrementAndGet(); + byte[] body = discoveryBody.getBytes(UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + server.createContext("/oauth2/.well-known/openid-configuration", exchange -> { + discoveryHits.incrementAndGet(); + byte[] body = discoveryBody.getBytes(UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + server.start(); + issuer = URI.create("http://localhost:" + server.getAddress().getPort()); + discovery = new OidcDiscovery(); + } + + @After + public void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + public void shouldBuildDiscoveryUrlWithoutTrailingSlash() { + assertThat(OidcDiscovery.configurationEndpoint(URI.create("https://accounts.google.com")) + .toString(), + is(equalTo("https://accounts.google.com/.well-known/openid-configuration"))); + } + + @Test + public void shouldBuildDiscoveryUrlWithTrailingSlash() { + assertThat(OidcDiscovery.configurationEndpoint(URI.create("https://accounts.google.com/")) + .toString(), + is(equalTo("https://accounts.google.com/.well-known/openid-configuration"))); + } + + @Test + public void shouldBuildDiscoveryUrlWithPath() { + assertThat(OidcDiscovery.configurationEndpoint( + URI.create("https://unity.example.org/oauth2")).toString(), + is(equalTo("https://unity.example.org/oauth2/.well-known/openid-configuration"))); + } + + @Test + public void shouldDiscoverAuthorizationAndTokenEndpoints() throws Exception { + assertThat(discovery.authorizationEndpoint(issuer), + is(equalTo("https://op.example/authorize"))); + assertThat(discovery.tokenEndpoint(issuer), is(equalTo("https://op.example/token"))); + assertThat(discoveryHits.get(), is(equalTo(1))); + } + + @Test + public void shouldDiscoverIssuerWithPath() throws Exception { + URI pathIssuer = URI.create(issuer.toString() + "/oauth2"); + assertThat(discovery.authorizationEndpoint(pathIssuer), + is(equalTo("https://op.example/authorize"))); + } + + @Test + public void shouldPreferConfiguredTokenUrl() throws Exception { + assertThat(discovery.resolveTokenEndpoint(issuer.toString(), "https://explicit.example/token"), + is(equalTo("https://explicit.example/token"))); + assertThat(discoveryHits.get(), is(equalTo(0))); + } + + @Test + public void shouldDiscoverTokenUrlWhenNotConfigured() throws Exception { + assertThat(discovery.resolveTokenEndpoint(issuer.toString(), " "), + is(equalTo("https://op.example/token"))); + } + + @Test(expected = IOException.class) + public void shouldFailWhenNeitherTokenUrlNorIssuerSet() throws Exception { + discovery.resolveTokenEndpoint("", null); + } + + @Test + public void shouldFillEmptyAuthzListFromIssuer() throws Exception { + OidcDiscoveryConfigDecorator decorator = new OidcDiscoveryConfigDecorator(); + decorator.setOidcDiscovery(discovery); + decorator.setIssuer(issuer.toString()); + + Map enriched = decorator.enrich( + Map.of("dcache-view.oidc-provider-name-list", "AS")); + + assertThat(enriched.get(OidcDiscoveryConfigDecorator.AUTHZ_ENDPOINT_LIST), + is(equalTo("https://op.example/authorize"))); + } + + @Test + public void shouldKeepExplicitAuthzList() throws Exception { + OidcDiscoveryConfigDecorator decorator = new OidcDiscoveryConfigDecorator(); + decorator.setOidcDiscovery(discovery); + decorator.setIssuer(issuer.toString()); + + Map enriched = decorator.enrich( + Map.of(OidcDiscoveryConfigDecorator.AUTHZ_ENDPOINT_LIST, + "https://explicit.example/authorize")); + + assertThat(enriched.get(OidcDiscoveryConfigDecorator.AUTHZ_ENDPOINT_LIST), + is(equalTo("https://explicit.example/authorize"))); + assertThat(discoveryHits.get(), is(equalTo(0))); + } +} diff --git a/skel/share/defaults/frontend.properties b/skel/share/defaults/frontend.properties index 0d16b3282cd..8e0c756fdd6 100644 --- a/skel/share/defaults/frontend.properties +++ b/skel/share/defaults/frontend.properties @@ -539,6 +539,7 @@ frontend.static.path = /scripts/config.js # dcache-view.oidc-provider-name-list # dcache-view.oidc-client-id-list # dcache-view.oidc-authz-endpoint-list +# dcache-view.oidc-issuer-list # # Support for OpenID Connect in dCacheView # @@ -546,7 +547,16 @@ frontend.static.path = /scripts/config.js # needs to be configured. This will enable user to be able # to authenticate with an OpenID connect account. # -# These 3 properties below must be set. +# Provider name and client-id lists must be set. The +# authorization endpoint list may be omitted when the +# corresponding issuer is known: the frontend then uses +# OpenID Connect Discovery +# ({issuer}/.well-known/openid-configuration) and takes +# authorization_endpoint from that document. +# +# Issuers are taken from dcache-view.oidc-issuer-list when +# that is set, otherwise from frontend.authn.oidc.issuer. +# An explicit oidc-authz-endpoint-list still wins. # # If you have more than one OpenID connect provider, each # property takes a space separated value; that is, one for @@ -555,11 +565,17 @@ frontend.static.path = /scripts/config.js # mapped together. # # Example: Say you have enable two OpenID connect providers; -# namely: openid1 and openid2. The 3 properties will be +# namely: openid1 and openid2. The properties will be # setup as follow: # # frontend.static!dcache-view.oidc-provider-name-list = openid1 openid2 # frontend.static!dcache-view.oidc-client-id-list = clientID1 clientID2 +# frontend.static!dcache-view.oidc-issuer-list = \ +# https://oidc.example.com \ +# https://auth.example.org +# +# Or, with explicit authorization endpoints (no discovery): +# # frontend.static!dcache-view.oidc-authz-endpoint-list = \ # https://oidc.example.com/authz \ # https://auth.example.org/authorize @@ -571,6 +587,7 @@ frontend.static!dcache-view.org-name = ${dcache.description} frontend.static!dcache-view.oidc-provider-name-list = frontend.static!dcache-view.oidc-client-id-list = frontend.static!dcache-view.oidc-authz-endpoint-list = +frontend.static!dcache-view.oidc-issuer-list = #--- Code Flow Authorisation ---- @@ -586,10 +603,20 @@ frontend.authn.oidc.client-id=141f817a5cda12d1dea3abb096623ea5fe5a11574cbe033744 frontend.authn.oidc.client-secret=gloas-9d45dbf5abb65e50577fef124bc9f600620e1528c804cee95e279ce354847eef +# `frontend.authn.oidc.issuer` +# The OpenID Provider issuer URL, as it appears in the `iss` claim. +# When set, the frontend fetches +# {issuer}/.well-known/openid-configuration and uses +# authorization_endpoint (for dCacheView) and token_endpoint (for +# the code-flow callback) unless those URLs are configured +# explicitly. +frontend.authn.oidc.issuer= + # `frontend.authn.oidc.token-url` # The **Token Endpoint** of your OIDC provider. This is where your app exchanges an authorization code for an access token. - -frontend.authn.oidc.token-url=https://gitlab.desy.de/oauth/token +# Optional when frontend.authn.oidc.issuer is set: the token_endpoint +# from the discovery document is used instead. +frontend.authn.oidc.token-url= # ---- Root path