Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/TheBook/src/main/markdown/config-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <client-id>
frontend.authn.oidc.client-secret = <client-secret>
frontend.static!dcache-view.oidc-provider-name-list = ExampleOP
frontend.static!dcache-view.oidc-client-id-list = <client-id>
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)
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OIDC
* Discovery</a>.
* <p>
* 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<URI, JSONObject> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Map<String, String>> {

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<String, String> delegate;
private String issuer;
private OidcDiscovery oidcDiscovery;

public void setDelegate(Map<String, String> delegate) {
this.delegate = delegate;
}

public void setIssuer(String issuer) {
this.issuer = issuer;
}

public void setOidcDiscovery(OidcDiscovery oidcDiscovery) {
this.oidcDiscovery = oidcDiscovery;
}

@Override
public Map<String, String> getObject() {
return enrich(delegate);
}

Map<String, String> enrich(Map<String, String> data) {
if (data == null) {
return ImmutableMap.of();
}
if (OidcDiscovery.hasText(data.get(AUTHZ_ENDPOINT_LIST))) {
return data;
}
List<String> issuers = issuersFrom(data);
if (issuers.isEmpty()) {
return data;
}
List<String> 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<String, String> copy = new LinkedHashMap<>(data);
copy.put(AUTHZ_ENDPOINT_LIST, String.join(" ", endpoints));
return ImmutableMap.copyOf(copy);
}

private List<String> issuersFrom(Map<String, String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,14 @@
<description>Provide dCache configuration as JSON or JavaScript</description>
<property name="path" value="${frontend.static.path}" />
<property name="data">
<bean class="org.dcache.util.configuration.ConfigurationMapFactoryBean">
<property name="prefix" value="frontend.static"/>
<bean class="org.dcache.restful.util.OidcDiscoveryConfigDecorator">
<property name="oidcDiscovery" ref="oidcDiscovery"/>
<property name="issuer" value="${frontend.authn.oidc.issuer}"/>
<property name="delegate">
<bean class="org.dcache.util.configuration.ConfigurationMapFactoryBean">
<property name="prefix" value="frontend.static"/>
</bean>
</property>
</bean>
</property>
</bean>
Expand Down Expand Up @@ -704,10 +710,14 @@
<property name="useQosService" value="${frontend.service.namespace.use-qos-service}"/>
</bean>

<bean id="oidcDiscovery" class="org.dcache.restful.util.OidcDiscovery"/>

<bean class="org.dcache.restful.resources.auth.OidcCodeFlowCallback" scope="request">
<property name="clientId" value="${frontend.authn.oidc.client-id}"/>
<property name="clientSecret" value="${frontend.authn.oidc.client-secret}"/>
<property name="tokenUrl" value="${frontend.authn.oidc.token-url}"/>
<property name="issuer" value="${frontend.authn.oidc.issuer}"/>
<property name="oidcDiscovery" ref="oidcDiscovery"/>
<property name="readOnly" value="${frontend.authz.readonly}"/>
<property name="loginStrategy" ref="cache-login-strategy"/>
</bean>
Expand Down
Loading
Loading