*", "- ");
+ // Convert HTML paragraphs
+ text = text.replaceAll("\\s*", "\n");
+
+ // Temporarily replace newlines in code blocks with a different character
+ // to preserve them during the next steps. Here we use 'SYMBOL FOR NEWLINE' (U+2424).
+ Matcher matcher = PRE_PATTERN.matcher(text);
+ StringBuilder sb = new StringBuilder();
+ while (matcher.find()) {
+ String codeBlock = matcher.group(1).trim();
+ codeBlock = codeBlock.replaceAll("[\r\n]", "\u2424");
+ matcher.appendReplacement(sb, "\n\n```\u2424" + codeBlock + "\u2424```\n\n");
+ }
+ matcher.appendTail(sb);
+ text = sb.toString();
+
+ return text;
+ }
+
+ private class Section {
+
+ private final JavaClass configClass;
+ private final String prefix;
+ private final String name;
+ private final String description;
+ private final Map refs = Maps.newLinkedHashMap();
+ private final List properties = Lists.newArrayList();
+
+ private Section(JavaClass configClass) {
+ this.configClass = configClass;
+ this.name = sanitizeSectionName(configClass.getSimpleName());
+ this.prefix = resolvePrefix();
+ parseLocalReferences();
+ this.description = sanitizeDescription(this, configClass.getComment());
+ }
+
+ private String sanitizeSectionName(String className) {
+ return className.replace("Config", "").replaceAll("([A-Z])", " $1").trim() + " Settings";
+ }
+
+ private String resolvePrefix() {
+ if (configClass.getSimpleName().equals("BasicConfig")) {
+ // Basic config: shares the root prefix
+ return rootPrefix;
+ } else {
+ JavaField prefixField = configClass.getFieldByName("PREFIX");
+ return prefixField
+ .getInitializationExpression()
+ .replace("OAuth2Config.PREFIX + ", rootPrefix)
+ .replace("\"", "");
+ }
+ }
+
+ private void parseLocalReferences() {
+ for (JavaField field : configClass.getFields()) {
+ String refName = field.getName();
+ if (refName.equals("PREFIX")) {
+ refs.put("PREFIX", prefix);
+ } else if (refName.startsWith("DEFAULT_")) {
+ String refText = field.getInitializationExpression();
+ if (refText.startsWith("Duration.parse")) {
+ refText = refText.substring(refText.indexOf("(\"") + 2, refText.indexOf("\")"));
+ } else {
+ String asRef = refText.replace('.', '#');
+ String resolved = KNOWN_REFS.get(asRef);
+ refText = resolved != null ? resolved : refText.replace("\"", "");
+ }
+ refs.put(refName, refText);
+ } else {
+ String refText =
+ field
+ .getInitializationExpression()
+ .replaceAll("PREFIX \\+\\s*", prefix)
+ .replace("\"", "");
+ refs.put(refName, refText);
+ }
+ }
+ }
+
+ private void parseProperties() {
+ for (JavaMethod method : configClass.getMethods()) {
+ if (method.getComment() == null) {
+ continue;
+ }
+ method.getAnnotations().stream()
+ .filter(a -> a.getType().getName().equals("ConfigOption"))
+ .findFirst()
+ .ifPresent(
+ ann -> {
+ String configOption = ann.getNamedParameter("value").toString();
+ String propertyName = refs.get(configOption);
+ String propertyDescription = sanitizeDescription(this, method.getComment());
+ if (ann.getNamedParameter("prefixMap") != null) {
+ boolean prefixMap =
+ Boolean.parseBoolean((String) ann.getNamedParameter("prefixMap"));
+ if (prefixMap) {
+ propertyName = propertyName + ".*";
+ }
+ }
+ properties.add(new Property(propertyName, propertyDescription));
+ });
+ }
+ }
+ }
+
+ private record Property(String name, String description) {}
+}
diff --git a/core/src/intTest/java/org/apache/iceberg/rest/auth/oauth2/ITOAuth2RESTCatalog.java b/core/src/intTest/java/org/apache/iceberg/rest/auth/oauth2/ITOAuth2RESTCatalog.java
new file mode 100644
index 000000000000..c1f7212cde4f
--- /dev/null
+++ b/core/src/intTest/java/org/apache/iceberg/rest/auth/oauth2/ITOAuth2RESTCatalog.java
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest.auth.oauth2;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.nimbusds.oauth2.sdk.GrantType;
+import com.nimbusds.oauth2.sdk.auth.Secret;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.nimbusds.oauth2.sdk.token.AccessToken;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.function.Consumer;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.CatalogTests;
+import org.apache.iceberg.exceptions.NotFoundException;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.rest.HTTPHeaders;
+import org.apache.iceberg.rest.HTTPRequest;
+import org.apache.iceberg.rest.RESTCatalog;
+import org.apache.iceberg.rest.RESTCatalogAdapter;
+import org.apache.iceberg.rest.RESTCatalogServlet;
+import org.apache.iceberg.rest.RESTResponse;
+import org.apache.iceberg.rest.auth.oauth2.client.OAuth2Client;
+import org.apache.iceberg.rest.auth.oauth2.config.ConfigUtil;
+import org.apache.iceberg.rest.auth.oauth2.test.ImmutableTestEnvironment;
+import org.apache.iceberg.rest.auth.oauth2.test.TestEnvironment;
+import org.apache.iceberg.rest.auth.oauth2.test.container.KeycloakContainer;
+import org.apache.iceberg.rest.auth.oauth2.test.junit.KeycloakExtension;
+import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * {@link CatalogTests} for {@link RESTCatalog} with {@link OAuth2Manager} and a real Keycloak.
+ *
+ * In these tests, OAuth2 is configured as follows:
+ *
+ *
+ * - Catalog session: uses client_credentials as an initial grant, and token exchange for token
+ * refreshes (legacy behavior).
+ *
- Session context: uses token exchange as the initial grant. The subject token is obtained
+ * off-band with the client_credentials grant. The actor token comes from the parent catalog
+ * session. Token refreshes use the standard refresh_token grant.
+ *
+ *
+ * The equivalent catalog configuration would be:
+ *
+ * {@code
+ * rest.auth.type=oauth2
+ * rest.auth.oauth2.issuer-url=https://
+ * rest.auth.oauth2.grant-type=client_credentials
+ * rest.auth.oauth2.client-id=
+ * rest.auth.oauth2.client-secret=
+ * rest.auth.oauth2.scope=catalog
+ * }
+ *
+ * The equivalent session context configuration would be:
+ *
+ * {@code
+ * rest.auth.oauth2.issuer-url=https://
+ * rest.auth.oauth2.grant-type=urn:ietf:params:oauth:grant-type:token-exchange
+ * rest.auth.oauth2.client-id=
+ * rest.auth.oauth2.client-secret=
+ * rest.auth.oauth2.scope=session
+ * rest.auth.oauth2.token-exchange.subject-token=
+ * rest.auth.oauth2.token-exchange.actor-token=::parent::
+ * }
+ */
+@ExtendWith(KeycloakExtension.class)
+public class ITOAuth2RESTCatalog extends CatalogTests {
+
+ @TempDir public static Path tempDir;
+
+ private static InMemoryCatalog backendCatalog;
+ private static Server httpServer;
+ private static TestEnvironment testEnvironment;
+
+ private RESTCatalog restCatalog;
+
+ @BeforeAll
+ static void beforeClass(
+ KeycloakContainer keycloakContainer,
+ ImmutableTestEnvironment.Builder envBuilder1,
+ ImmutableTestEnvironment.Builder envBuilder2)
+ throws Exception {
+ backendCatalog = new InMemoryCatalog();
+ backendCatalog.initialize(
+ "in-memory",
+ ImmutableMap.of(
+ CatalogProperties.WAREHOUSE_LOCATION,
+ tempDir.resolve("warehouse").toAbsolutePath().toString()));
+
+ ServletContextHandler servletContext =
+ new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
+ servletContext.addServlet(
+ new ServletHolder(
+ new RESTCatalogServlet(
+ new RESTCatalogAdapter(backendCatalog) {
+ @Override
+ public T execute(
+ HTTPRequest request,
+ Class responseType,
+ Consumer errorHandler,
+ Consumer