You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by dw...@apache.org on 2024/02/17 01:35:58 UTC

(iceberg) branch main updated: Support usage of Separate OIDC Authorization Server URI (#8976)

This is an automated email from the ASF dual-hosted git repository.

dweeks pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 9dcf8dbc42 Support usage of Separate OIDC Authorization Server URI (#8976)
9dcf8dbc42 is described below

commit 9dcf8dbc4285ad4ab4c1975562bf93fb04747cdd
Author: Sung Yun <10...@users.noreply.github.com>
AuthorDate: Fri Feb 16 18:35:53 2024 -0700

    Support usage of Separate OIDC Authorization Server URI (#8976)
    
    * configurable token-uri
    
    * more tests
    
    * lint
    
    * minor changes
    
    * suppress style
    
    * address feedback
    
    * adopt review feedback
    
    * update config name
    
    * lint
    
    * adopt review comments
---
 .../aws/s3/signer/S3V4RestSignerClient.java        |  16 +-
 .../java/org/apache/iceberg/rest/HTTPClient.java   |  29 +-
 .../apache/iceberg/rest/RESTSessionCatalog.java    |   7 +-
 .../apache/iceberg/rest/auth/OAuth2Properties.java |   3 +
 .../org/apache/iceberg/rest/auth/OAuth2Util.java   |  86 +++++-
 .../apache/iceberg/rest/RESTCatalogAdapter.java    |  66 ++--
 .../org/apache/iceberg/rest/TestRESTCatalog.java   | 342 +++++++++++++++------
 7 files changed, 391 insertions(+), 158 deletions(-)

diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3V4RestSignerClient.java b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3V4RestSignerClient.java
index 99f1588f0c..f284822161 100644
--- a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3V4RestSignerClient.java
+++ b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3V4RestSignerClient.java
@@ -41,6 +41,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.apache.iceberg.rest.ErrorHandlers;
 import org.apache.iceberg.rest.HTTPClient;
 import org.apache.iceberg.rest.RESTClient;
+import org.apache.iceberg.rest.ResourcePaths;
 import org.apache.iceberg.rest.auth.OAuth2Properties;
 import org.apache.iceberg.rest.auth.OAuth2Util;
 import org.apache.iceberg.rest.auth.OAuth2Util.AuthSession;
@@ -111,6 +112,12 @@ public abstract class S3V4RestSignerClient
     return properties().get(OAuth2Properties.CREDENTIAL);
   }
 
+  /** Token endpoint URI to fetch token from if the Rest Catalog is not the authorization server. */
+  @Value.Lazy
+  public String oauth2ServerUri() {
+    return properties().getOrDefault(OAuth2Properties.OAUTH2_SERVER_URI, ResourcePaths.tokens());
+  }
+
   /** A Bearer token supplier which will be used for interaction with the server. */
   @Value.Default
   public Supplier<String> token() {
@@ -199,7 +206,8 @@ public abstract class S3V4RestSignerClient
                       tokenRefreshExecutor(),
                       token,
                       expiresAtMillis(properties()),
-                      new AuthSession(ImmutableMap.of(), token, null, credential(), SCOPE)));
+                      new AuthSession(
+                          ImmutableMap.of(), token, null, credential(), SCOPE, oauth2ServerUri())));
     }
 
     if (credentialProvided()) {
@@ -208,10 +216,12 @@ public abstract class S3V4RestSignerClient
               credential(),
               id -> {
                 AuthSession session =
-                    new AuthSession(ImmutableMap.of(), null, null, credential(), SCOPE);
+                    new AuthSession(
+                        ImmutableMap.of(), null, null, credential(), SCOPE, oauth2ServerUri());
                 long startTimeMillis = System.currentTimeMillis();
                 OAuthTokenResponse authResponse =
-                    OAuth2Util.fetchToken(httpClient(), session.headers(), credential(), SCOPE);
+                    OAuth2Util.fetchToken(
+                        httpClient(), session.headers(), credential(), SCOPE, oauth2ServerUri());
                 return AuthSession.fromTokenResponse(
                     httpClient(), tokenRefreshExecutor(), authResponse, startTimeMillis, session);
               });
diff --git a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
index 55ed1422b3..9366630954 100644
--- a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
+++ b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java
@@ -205,16 +205,24 @@ public class HTTPClient implements RESTClient {
   }
 
   private URI buildUri(String path, Map<String, String> params) {
-    String baseUri = String.format("%s/%s", uri, path);
+    // if full path is provided, use the input path as path
+    if (path.startsWith("/")) {
+      throw new RESTException(
+          "Received a malformed path for a REST request: %s. Paths should not start with /", path);
+    }
+    String fullPath =
+        (path.startsWith("https://") || path.startsWith("http://"))
+            ? path
+            : String.format("%s/%s", uri, path);
     try {
-      URIBuilder builder = new URIBuilder(baseUri);
+      URIBuilder builder = new URIBuilder(fullPath);
       if (params != null) {
         params.forEach(builder::addParameter);
       }
       return builder.build();
     } catch (URISyntaxException e) {
       throw new RESTException(
-          "Failed to create request URI from base %s, params %s", baseUri, params);
+          "Failed to create request URI from base %s, params %s", fullPath, params);
     }
   }
 
@@ -223,7 +231,7 @@ public class HTTPClient implements RESTClient {
    *
    * @param method - HTTP method, such as GET, POST, HEAD, etc.
    * @param queryParams - A map of query parameters
-   * @param path - URL path to send the request to
+   * @param path - URI to send the request to
    * @param requestBody - Content to place in the request body
    * @param responseType - Class of the Response type. Needs to have serializer registered with
    *     ObjectMapper
@@ -250,7 +258,7 @@ public class HTTPClient implements RESTClient {
    *
    * @param method - HTTP method, such as GET, POST, HEAD, etc.
    * @param queryParams - A map of query parameters
-   * @param path - URL path to send the request to
+   * @param path - URL to send the request to
    * @param requestBody - Content to place in the request body
    * @param responseType - Class of the Response type. Needs to have serializer registered with
    *     ObjectMapper
@@ -270,11 +278,6 @@ public class HTTPClient implements RESTClient {
       Map<String, String> headers,
       Consumer<ErrorResponse> errorHandler,
       Consumer<Map<String, String>> responseHeaders) {
-    if (path.startsWith("/")) {
-      throw new RESTException(
-          "Received a malformed path for a REST request: %s. Paths should not start with /", path);
-    }
-
     HttpUriRequestBase request = new HttpUriRequestBase(method.name(), buildUri(path, queryParams));
 
     if (requestBody instanceof Map) {
@@ -459,9 +462,9 @@ public class HTTPClient implements RESTClient {
       this.properties = properties;
     }
 
-    public Builder uri(String baseUri) {
-      Preconditions.checkNotNull(baseUri, "Invalid uri for http client: null");
-      this.uri = RESTUtil.stripTrailingSlash(baseUri);
+    public Builder uri(String path) {
+      Preconditions.checkNotNull(path, "Invalid uri for http client: null");
+      this.uri = RESTUtil.stripTrailingSlash(path);
       return this;
     }
 
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
index 5f660f0f4f..87519cfd9d 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
@@ -178,11 +178,14 @@ public class RESTSessionCatalog extends BaseViewSessionCatalog
     OAuthTokenResponse authResponse;
     String credential = props.get(OAuth2Properties.CREDENTIAL);
     String scope = props.getOrDefault(OAuth2Properties.SCOPE, OAuth2Properties.CATALOG_SCOPE);
+    String oauth2ServerUri =
+        props.getOrDefault(OAuth2Properties.OAUTH2_SERVER_URI, ResourcePaths.tokens());
     try (RESTClient initClient = clientBuilder.apply(props)) {
       Map<String, String> initHeaders =
           RESTUtil.merge(configHeaders(props), OAuth2Util.authHeaders(initToken));
       if (credential != null && !credential.isEmpty()) {
-        authResponse = OAuth2Util.fetchToken(initClient, initHeaders, credential, scope);
+        authResponse =
+            OAuth2Util.fetchToken(initClient, initHeaders, credential, scope, oauth2ServerUri);
         Map<String, String> authHeaders =
             RESTUtil.merge(initHeaders, OAuth2Util.authHeaders(authResponse.token()));
         config = fetchConfig(initClient, authHeaders, props);
@@ -209,7 +212,7 @@ public class RESTSessionCatalog extends BaseViewSessionCatalog
     this.paths = ResourcePaths.forCatalogProperties(mergedProps);
 
     String token = mergedProps.get(OAuth2Properties.TOKEN);
-    this.catalogAuth = new AuthSession(baseHeaders, null, null, credential, scope);
+    this.catalogAuth = new AuthSession(baseHeaders, null, null, credential, scope, oauth2ServerUri);
     if (authResponse != null) {
       this.catalogAuth =
           AuthSession.fromTokenResponse(
diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Properties.java b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Properties.java
index 8bd75c5ebb..e1a9181d16 100644
--- a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Properties.java
+++ b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Properties.java
@@ -27,6 +27,9 @@ public class OAuth2Properties {
   /** A credential to exchange for a token in the OAuth2 client credentials flow. */
   public static final String CREDENTIAL = "credential";
 
+  /** Token endpoint URI to fetch token from if the Rest Catalog is not the authorization server. */
+  public static final String OAUTH2_SERVER_URI = "oauth2-server-uri";
+
   /**
    * Interval in milliseconds to wait before attempting to exchange the configured catalog Bearer
    * token. By default, token exchange will be attempted after 1 hour.
diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java
index 678642830c..ad1821a3f2 100644
--- a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java
+++ b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java
@@ -134,7 +134,8 @@ public class OAuth2Util {
       Map<String, String> headers,
       String subjectToken,
       String subjectTokenType,
-      String scope) {
+      String scope,
+      String oauth2ServerUri) {
     Map<String, String> request =
         tokenExchangeRequest(
             subjectToken,
@@ -143,7 +144,7 @@ public class OAuth2Util {
 
     OAuthTokenResponse response =
         client.postForm(
-            ResourcePaths.tokens(),
+            oauth2ServerUri,
             request,
             OAuthTokenResponse.class,
             headers,
@@ -160,7 +161,8 @@ public class OAuth2Util {
       String subjectTokenType,
       String actorToken,
       String actorTokenType,
-      String scope) {
+      String scope,
+      String oauth2ServerUri) {
     Map<String, String> request =
         tokenExchangeRequest(
             subjectToken,
@@ -171,7 +173,7 @@ public class OAuth2Util {
 
     OAuthTokenResponse response =
         client.postForm(
-            ResourcePaths.tokens(),
+            oauth2ServerUri,
             request,
             OAuthTokenResponse.class,
             headers,
@@ -181,15 +183,38 @@ public class OAuth2Util {
     return response;
   }
 
+  public static OAuthTokenResponse exchangeToken(
+      RESTClient client,
+      Map<String, String> headers,
+      String subjectToken,
+      String subjectTokenType,
+      String actorToken,
+      String actorTokenType,
+      String scope) {
+    return exchangeToken(
+        client,
+        headers,
+        subjectToken,
+        subjectTokenType,
+        actorToken,
+        actorTokenType,
+        scope,
+        ResourcePaths.tokens());
+  }
+
   public static OAuthTokenResponse fetchToken(
-      RESTClient client, Map<String, String> headers, String credential, String scope) {
+      RESTClient client,
+      Map<String, String> headers,
+      String credential,
+      String scope,
+      String oauth2ServerUri) {
     Map<String, String> request =
         clientCredentialsRequest(
             credential, scope != null ? ImmutableList.of(scope) : ImmutableList.of());
 
     OAuthTokenResponse response =
         client.postForm(
-            ResourcePaths.tokens(),
+            oauth2ServerUri,
             request,
             OAuthTokenResponse.class,
             headers,
@@ -199,6 +224,12 @@ public class OAuth2Util {
     return response;
   }
 
+  public static OAuthTokenResponse fetchToken(
+      RESTClient client, Map<String, String> headers, String credential, String scope) {
+
+    return fetchToken(client, headers, credential, scope, ResourcePaths.tokens());
+  }
+
   private static Map<String, String> tokenExchangeRequest(
       String subjectToken, String subjectTokenType, List<String> scopes) {
     return tokenExchangeRequest(subjectToken, subjectTokenType, null, null, scopes);
@@ -361,7 +392,26 @@ public class OAuth2Util {
     private final String credential;
     private final String scope;
     private volatile boolean keepRefreshed = true;
+    private final String oauth2ServerUri;
 
+    public AuthSession(
+        Map<String, String> baseHeaders,
+        String token,
+        String tokenType,
+        String credential,
+        String scope,
+        String oauth2ServerUri) {
+      this.headers = RESTUtil.merge(baseHeaders, authHeaders(token));
+      this.token = token;
+      this.tokenType = tokenType;
+      this.expiresAtMillis = OAuth2Util.expiresAtMillis(token);
+      this.credential = credential;
+      this.scope = scope;
+      this.oauth2ServerUri = oauth2ServerUri;
+    }
+
+    /** @deprecated since 1.5.0, will be removed in 1.6.0 */
+    @Deprecated
     public AuthSession(
         Map<String, String> baseHeaders,
         String token,
@@ -374,6 +424,7 @@ public class OAuth2Util {
       this.expiresAtMillis = OAuth2Util.expiresAtMillis(token);
       this.credential = credential;
       this.scope = scope;
+      this.oauth2ServerUri = ResourcePaths.tokens();
     }
 
     public Map<String, String> headers() {
@@ -404,6 +455,10 @@ public class OAuth2Util {
       return credential;
     }
 
+    public String oauth2ServerUri() {
+      return oauth2ServerUri;
+    }
+
     @VisibleForTesting
     static void setTokenRefreshNumRetries(int retries) {
       tokenRefreshNumRetries = retries;
@@ -415,7 +470,8 @@ public class OAuth2Util {
      * @return A new {@link AuthSession} with empty headers.
      */
     public static AuthSession empty() {
-      return new AuthSession(ImmutableMap.of(), null, null, null, OAuth2Properties.CATALOG_SCOPE);
+      return new AuthSession(
+          ImmutableMap.of(), null, null, null, OAuth2Properties.CATALOG_SCOPE, null);
     }
 
     /**
@@ -470,14 +526,14 @@ public class OAuth2Util {
         return refreshExpiredToken(client);
       } else {
         // attempt a normal refresh
-        return refreshToken(client, headers(), token, tokenType, scope);
+        return refreshToken(client, headers(), token, tokenType, scope, oauth2ServerUri);
       }
     }
 
     private OAuthTokenResponse refreshExpiredToken(RESTClient client) {
       if (credential != null) {
         Map<String, String> basicHeaders = RESTUtil.merge(headers(), basicAuthHeaders(credential));
-        return refreshToken(client, basicHeaders, token, tokenType, scope);
+        return refreshToken(client, basicHeaders, token, tokenType, scope, oauth2ServerUri);
       }
 
       return null;
@@ -533,7 +589,8 @@ public class OAuth2Util {
               token,
               OAuth2Properties.ACCESS_TOKEN_TYPE,
               parent.credential(),
-              parent.scope());
+              parent.scope(),
+              parent.oauth2ServerUri());
 
       long startTimeMillis = System.currentTimeMillis();
       Long expiresAtMillis = session.expiresAtMillis();
@@ -571,7 +628,8 @@ public class OAuth2Util {
         AuthSession parent) {
       long startTimeMillis = System.currentTimeMillis();
       OAuthTokenResponse response =
-          fetchToken(client, parent.headers(), credential, parent.scope());
+          fetchToken(
+              client, parent.headers(), credential, parent.scope(), parent.oauth2ServerUri());
       return fromTokenResponse(client, executor, response, startTimeMillis, parent, credential);
     }
 
@@ -598,7 +656,8 @@ public class OAuth2Util {
               response.token(),
               response.issuedTokenType(),
               credential,
-              parent.scope());
+              parent.scope(),
+              parent.oauth2ServerUri());
 
       Long expiresAtMillis = session.expiresAtMillis();
       if (null == expiresAtMillis && response.expiresInSeconds() != null) {
@@ -627,7 +686,8 @@ public class OAuth2Util {
               tokenType,
               parent.token(),
               parent.tokenType(),
-              parent.scope());
+              parent.scope(),
+              parent.oauth2ServerUri());
       return fromTokenResponse(client, executor, response, startTimeMillis, parent);
     }
   }
diff --git a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
index 1974838ede..7fccc4e974 100644
--- a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
+++ b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
@@ -112,6 +112,8 @@ public class RESTCatalogAdapter implements RESTClient {
 
   enum Route {
     TOKENS(HTTPMethod.POST, "v1/oauth/tokens", null, OAuthTokenResponse.class),
+    SEPARATE_AUTH_TOKENS_URI(
+        HTTPMethod.POST, "https://auth-server.com/token", null, OAuthTokenResponse.class),
     CONFIG(HTTPMethod.GET, "v1/config", null, ConfigResponse.class),
     LIST_NAMESPACES(HTTPMethod.GET, "v1/namespaces", null, ListNamespacesResponse.class),
     CREATE_NAMESPACE(
@@ -245,43 +247,40 @@ public class RESTCatalogAdapter implements RESTClient {
     }
   }
 
+  private static OAuthTokenResponse handleOAuthRequest(Object body) {
+    Map<String, String> request = (Map<String, String>) castRequest(Map.class, body);
+    String grantType = request.get("grant_type");
+    switch (grantType) {
+      case "client_credentials":
+        return OAuthTokenResponse.builder()
+            .withToken("client-credentials-token:sub=" + request.get("client_id"))
+            .withIssuedTokenType("urn:ietf:params:oauth:token-type:access_token")
+            .withTokenType("Bearer")
+            .build();
+
+      case "urn:ietf:params:oauth:grant-type:token-exchange":
+        String actor = request.get("actor_token");
+        String token =
+            String.format(
+                "token-exchange-token:sub=%s%s",
+                request.get("subject_token"), actor != null ? ",act=" + actor : "");
+        return OAuthTokenResponse.builder()
+            .withToken(token)
+            .withIssuedTokenType("urn:ietf:params:oauth:token-type:access_token")
+            .withTokenType("Bearer")
+            .build();
+
+      default:
+        throw new UnsupportedOperationException("Unsupported grant_type: " + grantType);
+    }
+  }
+
   @SuppressWarnings({"MethodLength", "checkstyle:CyclomaticComplexity"})
   public <T extends RESTResponse> T handleRequest(
       Route route, Map<String, String> vars, Object body, Class<T> responseType) {
     switch (route) {
       case TOKENS:
-        {
-          @SuppressWarnings("unchecked")
-          Map<String, String> request = (Map<String, String>) castRequest(Map.class, body);
-          String grantType = request.get("grant_type");
-          switch (grantType) {
-            case "client_credentials":
-              return castResponse(
-                  responseType,
-                  OAuthTokenResponse.builder()
-                      .withToken("client-credentials-token:sub=" + request.get("client_id"))
-                      .withIssuedTokenType("urn:ietf:params:oauth:token-type:access_token")
-                      .withTokenType("Bearer")
-                      .build());
-
-            case "urn:ietf:params:oauth:grant-type:token-exchange":
-              String actor = request.get("actor_token");
-              String token =
-                  String.format(
-                      "token-exchange-token:sub=%s%s",
-                      request.get("subject_token"), actor != null ? ",act=" + actor : "");
-              return castResponse(
-                  responseType,
-                  OAuthTokenResponse.builder()
-                      .withToken(token)
-                      .withIssuedTokenType("urn:ietf:params:oauth:token-type:access_token")
-                      .withTokenType("Bearer")
-                      .build());
-
-            default:
-              throw new UnsupportedOperationException("Unsupported grant_type: " + grantType);
-          }
-        }
+        return castResponse(responseType, handleOAuthRequest(body));
 
       case CONFIG:
         return castResponse(responseType, ConfigResponse.builder().build());
@@ -469,6 +468,9 @@ public class RESTCatalogAdapter implements RESTClient {
         }
 
       default:
+        if (responseType == OAuthTokenResponse.class) {
+          return castResponse(responseType, handleOAuthRequest(body));
+        }
     }
 
     return null;
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
index 0e260a4e53..cd7710d460 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
@@ -353,8 +353,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             any());
   }
 
-  @Test
-  public void testCatalogCredential() {
+  public void testCatalogCredentialNoOauth2ServerUri() {
     Map<String, String> emptyHeaders = ImmutableMap.of();
     Map<String, String> catalogHeaders =
         ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=catalog");
@@ -400,8 +399,64 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             any());
   }
 
-  @Test
-  public void testCatalogBearerTokenWithClientCredential() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogCredential(String oauth2ServerUri) {
+    Map<String, String> emptyHeaders = ImmutableMap.of();
+    Map<String, String> catalogHeaders =
+        ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=catalog");
+
+    RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog));
+
+    RESTCatalog catalog =
+        new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter);
+    catalog.initialize(
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI,
+            "ignored",
+            "credential",
+            "catalog:secret",
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
+
+    Assertions.assertThat(catalog.tableExists(TableIdentifier.of("ns", "table"))).isFalse();
+
+    // no token or credential for catalog token exchange
+    Mockito.verify(adapter)
+        .execute(
+            eq(HTTPMethod.POST),
+            eq(oauth2ServerUri),
+            any(),
+            any(),
+            eq(OAuthTokenResponse.class),
+            eq(emptyHeaders),
+            any());
+    // no token or credential for config
+    Mockito.verify(adapter)
+        .execute(
+            eq(HTTPMethod.GET),
+            eq("v1/config"),
+            any(),
+            any(),
+            eq(ConfigResponse.class),
+            eq(catalogHeaders),
+            any());
+    // use the catalog token for all interactions
+    Mockito.verify(adapter)
+        .execute(
+            eq(HTTPMethod.GET),
+            eq("v1/namespaces/ns/tables/table"),
+            any(),
+            any(),
+            eq(LoadTableResponse.class),
+            eq(catalogHeaders),
+            any());
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogBearerTokenWithClientCredential(String oauth2ServerUri) {
     Map<String, String> contextHeaders =
         ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=user");
     Map<String, String> catalogHeaders = ImmutableMap.of("Authorization", "Bearer bearer-token");
@@ -417,7 +472,14 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
 
     RESTCatalog catalog = new RESTCatalog(context, (config) -> adapter);
     catalog.initialize(
-        "prod", ImmutableMap.of(CatalogProperties.URI, "ignored", "token", "bearer-token"));
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI,
+            "ignored",
+            "token",
+            "bearer-token",
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Assertions.assertThat(catalog.tableExists(TableIdentifier.of("ns", "table"))).isFalse();
 
@@ -435,7 +497,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -453,8 +515,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             any());
   }
 
-  @Test
-  public void testCatalogCredentialWithClientCredential() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogCredentialWithClientCredential(String oauth2ServerUri) {
     Map<String, String> emptyHeaders = ImmutableMap.of();
     Map<String, String> contextHeaders =
         ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=user");
@@ -472,7 +535,14 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
 
     RESTCatalog catalog = new RESTCatalog(context, (config) -> adapter);
     catalog.initialize(
-        "prod", ImmutableMap.of(CatalogProperties.URI, "ignored", "credential", "catalog:secret"));
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI,
+            "ignored",
+            "credential",
+            "catalog:secret",
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Assertions.assertThat(catalog.tableExists(TableIdentifier.of("ns", "table"))).isFalse();
 
@@ -480,7 +550,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -500,7 +570,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -518,8 +588,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             any());
   }
 
-  @Test
-  public void testCatalogBearerTokenAndCredentialWithClientCredential() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogBearerTokenAndCredentialWithClientCredential(String oauth2ServerUri) {
     Map<String, String> contextHeaders =
         ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=user");
     Map<String, String> initHeaders = ImmutableMap.of("Authorization", "Bearer bearer-token");
@@ -544,7 +615,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             "credential",
             "catalog:secret",
             "token",
-            "bearer-token"));
+            "bearer-token",
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Assertions.assertThat(catalog.tableExists(TableIdentifier.of("ns", "table"))).isFalse();
 
@@ -552,7 +625,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -572,7 +645,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -590,8 +663,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             any());
   }
 
-  @Test
-  public void testClientBearerToken() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testClientBearerToken(String oauth2ServerUri) {
     testClientAuth(
         "bearer-token",
         ImmutableMap.of(
@@ -602,11 +676,13 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             "urn:ietf:params:oauth:token-type:jwt", "jwt-token",
             "urn:ietf:params:oauth:token-type:saml2", "saml2-token",
             "urn:ietf:params:oauth:token-type:saml1", "saml1-token"),
-        ImmutableMap.of("Authorization", "Bearer client-bearer-token"));
+        ImmutableMap.of("Authorization", "Bearer client-bearer-token"),
+        oauth2ServerUri);
   }
 
-  @Test
-  public void testClientCredential() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testClientCredential(String oauth2ServerUri) {
     testClientAuth(
         "bearer-token",
         ImmutableMap.of(
@@ -616,11 +692,13 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             "urn:ietf:params:oauth:token-type:jwt", "jwt-token",
             "urn:ietf:params:oauth:token-type:saml2", "saml2-token",
             "urn:ietf:params:oauth:token-type:saml1", "saml1-token"),
-        ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=user"));
+        ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=user"),
+        oauth2ServerUri);
   }
 
-  @Test
-  public void testClientIDToken() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testClientIDToken(String oauth2ServerUri) {
     testClientAuth(
         "bearer-token",
         ImmutableMap.of(
@@ -630,11 +708,13 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             "urn:ietf:params:oauth:token-type:saml2", "saml2-token",
             "urn:ietf:params:oauth:token-type:saml1", "saml1-token"),
         ImmutableMap.of(
-            "Authorization", "Bearer token-exchange-token:sub=id-token,act=bearer-token"));
+            "Authorization", "Bearer token-exchange-token:sub=id-token,act=bearer-token"),
+        oauth2ServerUri);
   }
 
-  @Test
-  public void testClientAccessToken() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testClientAccessToken(String oauth2ServerUri) {
     testClientAuth(
         "bearer-token",
         ImmutableMap.of(
@@ -643,11 +723,13 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             "urn:ietf:params:oauth:token-type:saml2", "saml2-token",
             "urn:ietf:params:oauth:token-type:saml1", "saml1-token"),
         ImmutableMap.of(
-            "Authorization", "Bearer token-exchange-token:sub=access-token,act=bearer-token"));
+            "Authorization", "Bearer token-exchange-token:sub=access-token,act=bearer-token"),
+        oauth2ServerUri);
   }
 
-  @Test
-  public void testClientJWTToken() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testClientJWTToken(String oauth2ServerUri) {
     testClientAuth(
         "bearer-token",
         ImmutableMap.of(
@@ -655,31 +737,39 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             "urn:ietf:params:oauth:token-type:saml2", "saml2-token",
             "urn:ietf:params:oauth:token-type:saml1", "saml1-token"),
         ImmutableMap.of(
-            "Authorization", "Bearer token-exchange-token:sub=jwt-token,act=bearer-token"));
+            "Authorization", "Bearer token-exchange-token:sub=jwt-token,act=bearer-token"),
+        oauth2ServerUri);
   }
 
-  @Test
-  public void testClientSAML2Token() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testClientSAML2Token(String oauth2ServerUri) {
     testClientAuth(
         "bearer-token",
         ImmutableMap.of(
             "urn:ietf:params:oauth:token-type:saml2", "saml2-token",
             "urn:ietf:params:oauth:token-type:saml1", "saml1-token"),
         ImmutableMap.of(
-            "Authorization", "Bearer token-exchange-token:sub=saml2-token,act=bearer-token"));
+            "Authorization", "Bearer token-exchange-token:sub=saml2-token,act=bearer-token"),
+        oauth2ServerUri);
   }
 
-  @Test
-  public void testClientSAML1Token() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testClientSAML1Token(String oauth2ServerUri) {
     testClientAuth(
         "bearer-token",
         ImmutableMap.of("urn:ietf:params:oauth:token-type:saml1", "saml1-token"),
         ImmutableMap.of(
-            "Authorization", "Bearer token-exchange-token:sub=saml1-token,act=bearer-token"));
+            "Authorization", "Bearer token-exchange-token:sub=saml1-token,act=bearer-token"),
+        oauth2ServerUri);
   }
 
   private void testClientAuth(
-      String catalogToken, Map<String, String> credentials, Map<String, String> expectedHeaders) {
+      String catalogToken,
+      Map<String, String> credentials,
+      Map<String, String> expectedHeaders,
+      String oauth2ServerUri) {
     Map<String, String> catalogHeaders = ImmutableMap.of("Authorization", "Bearer " + catalogToken);
 
     RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog));
@@ -690,7 +780,14 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
 
     RESTCatalog catalog = new RESTCatalog(context, (config) -> adapter);
     catalog.initialize(
-        "prod", ImmutableMap.of(CatalogProperties.URI, "ignored", "token", catalogToken));
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI,
+            "ignored",
+            "token",
+            catalogToken,
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Assertions.assertThat(catalog.tableExists(TableIdentifier.of("ns", "table"))).isFalse();
 
@@ -710,7 +807,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
       Mockito.verify(adapter)
           .execute(
               eq(HTTPMethod.POST),
-              eq("v1/oauth/tokens"),
+              eq(oauth2ServerUri),
               any(),
               any(),
               eq(OAuthTokenResponse.class),
@@ -729,18 +826,21 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             any());
   }
 
-  @Test
-  public void testTableBearerToken() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testTableBearerToken(String oauth2ServerUri) {
     testTableAuth(
         "catalog",
         ImmutableMap.of("urn:ietf:params:oauth:token-type:id_token", "id-token"),
         ImmutableMap.of("token", "table-bearer-token"),
         ImmutableMap.of("Authorization", "Bearer token-exchange-token:sub=id-token,act=catalog"),
-        ImmutableMap.of("Authorization", "Bearer table-bearer-token"));
+        ImmutableMap.of("Authorization", "Bearer table-bearer-token"),
+        oauth2ServerUri);
   }
 
-  @Test
-  public void testTableIDToken() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testTableIDToken(String oauth2ServerUri) {
     testTableAuth(
         "catalog",
         ImmutableMap.of("urn:ietf:params:oauth:token-type:id_token", "id-token"),
@@ -748,17 +848,20 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
         ImmutableMap.of("Authorization", "Bearer token-exchange-token:sub=id-token,act=catalog"),
         ImmutableMap.of(
             "Authorization",
-            "Bearer token-exchange-token:sub=table-id-token,act=token-exchange-token:sub=id-token,act=catalog"));
+            "Bearer token-exchange-token:sub=table-id-token,act=token-exchange-token:sub=id-token,act=catalog"),
+        oauth2ServerUri);
   }
 
-  @Test
-  public void testTableCredential() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testTableCredential(String oauth2ServerUri) {
     testTableAuth(
         "catalog",
         ImmutableMap.of("urn:ietf:params:oauth:token-type:id_token", "id-token"),
         ImmutableMap.of("credential", "table-user:secret"),
         ImmutableMap.of("Authorization", "Bearer token-exchange-token:sub=id-token,act=catalog"),
-        ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=table-user"));
+        ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=table-user"),
+        oauth2ServerUri);
   }
 
   @Test
@@ -914,7 +1017,6 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     String branch = "divergedBranch";
     table.manageSnapshots().createBranch(branch, table.currentSnapshot().snapshotId()).commit();
 
-    // branch and main are diverged now
     table
         .newFastAppend()
         .appendFile(
@@ -1095,12 +1197,14 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     assertThat(refsTables.history()).hasSize(numSnapshots);
   }
 
+  @SuppressWarnings("MethodLength")
   public void testTableAuth(
       String catalogToken,
       Map<String, String> credentials,
       Map<String, String> tableConfig,
       Map<String, String> expectedContextHeaders,
-      Map<String, String> expectedTableHeaders) {
+      Map<String, String> expectedTableHeaders,
+      String oauth2ServerUri) {
     TableIdentifier ident = TableIdentifier.of("ns", "table");
     Map<String, String> catalogHeaders = ImmutableMap.of("Authorization", "Bearer " + catalogToken);
 
@@ -1145,7 +1249,14 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
 
     RESTCatalog catalog = new RESTCatalog(context, (config) -> adapter);
     catalog.initialize(
-        "prod", ImmutableMap.of(CatalogProperties.URI, "ignored", "token", catalogToken));
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI,
+            "ignored",
+            "token",
+            catalogToken,
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Schema expectedSchema =
         new Schema(
@@ -1181,7 +1292,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -1205,7 +1316,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
       Mockito.verify(adapter, times(1))
           .execute(
               eq(HTTPMethod.POST),
-              eq("v1/oauth/tokens"),
+              eq(oauth2ServerUri),
               any(),
               any(),
               eq(OAuthTokenResponse.class),
@@ -1247,8 +1358,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             any());
   }
 
-  @Test
-  public void testCatalogTokenRefresh() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogTokenRefresh(String oauth2ServerUri) {
     Map<String, String> emptyHeaders = ImmutableMap.of();
     Map<String, String> catalogHeaders =
         ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=catalog");
@@ -1271,7 +1383,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
         .when(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -1285,7 +1397,14 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
 
     RESTCatalog catalog = new RESTCatalog(context, (config) -> adapter);
     catalog.initialize(
-        "prod", ImmutableMap.of(CatalogProperties.URI, "ignored", "credential", "catalog:secret"));
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI,
+            "ignored",
+            "credential",
+            "catalog:secret",
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Awaitility.await()
         .atMost(5, TimeUnit.SECONDS)
@@ -1295,7 +1414,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter)
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       any(),
                       eq(OAuthTokenResponse.class),
@@ -1323,7 +1442,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter)
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       Mockito.argThat(firstRefreshRequest::equals),
                       eq(OAuthTokenResponse.class),
@@ -1345,7 +1464,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter)
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       Mockito.argThat(secondRefreshRequest::equals),
                       eq(OAuthTokenResponse.class),
@@ -1354,8 +1473,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             });
   }
 
-  @Test
-  public void testCatalogRefreshedTokenIsUsed() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogRefreshedTokenIsUsed(String oauth2ServerUri) {
     Map<String, String> emptyHeaders = ImmutableMap.of();
     Map<String, String> catalogHeaders =
         ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=catalog");
@@ -1378,7 +1498,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
         .when(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -1392,7 +1512,14 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
 
     RESTCatalog catalog = new RESTCatalog(context, (config) -> adapter);
     catalog.initialize(
-        "prod", ImmutableMap.of(CatalogProperties.URI, "ignored", "credential", "catalog:secret"));
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI,
+            "ignored",
+            "credential",
+            "catalog:secret",
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Awaitility.await()
         .atMost(5, TimeUnit.SECONDS)
@@ -1406,7 +1533,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter)
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       any(),
                       eq(OAuthTokenResponse.class),
@@ -1434,7 +1561,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter)
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       Mockito.argThat(firstRefreshRequest::equals),
                       eq(OAuthTokenResponse.class),
@@ -1531,8 +1658,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     catalog.initialize("prod", ImmutableMap.of(CatalogProperties.URI, "ignored", "token", token));
   }
 
-  @Test
-  public void testCatalogExpiredBearerTokenIsRefreshedWithCredential() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogExpiredBearerTokenIsRefreshedWithCredential(String oauth2ServerUri) {
     // expires at epoch second = 1
     String token =
         "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjF9.gQADTbdEv-rpDWKSkGLbmafyB5UUjTdm9B_1izpuZ6E";
@@ -1551,7 +1679,14 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     RESTCatalog catalog = new RESTCatalog(context, (config) -> adapter);
     // the init token at the catalog level is a valid token
     catalog.initialize(
-        "prod", ImmutableMap.of(CatalogProperties.URI, "ignored", "credential", credential));
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI,
+            "ignored",
+            "credential",
+            credential,
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Assertions.assertThat(catalog.tableExists(TableIdentifier.of("ns", "table"))).isFalse();
 
@@ -1559,7 +1694,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -1585,7 +1720,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             Mockito.argThat(firstRefreshRequest::equals),
             eq(OAuthTokenResponse.class),
@@ -1602,7 +1737,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             Mockito.argThat(secondRefreshRequest::equals),
             eq(OAuthTokenResponse.class),
@@ -1662,8 +1797,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             any());
   }
 
-  @Test
-  public void testCatalogTokenRefreshFailsAndUsesCredentialForRefresh() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogTokenRefreshFailsAndUsesCredentialForRefresh(String oauth2ServerUri) {
     Map<String, String> emptyHeaders = ImmutableMap.of();
     Map<String, String> catalogHeaders =
         ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=catalog");
@@ -1689,7 +1825,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
         .when(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -1708,7 +1844,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
         .when(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             Mockito.argThat(firstRefreshRequest::equals),
             eq(OAuthTokenResponse.class),
@@ -1725,7 +1861,14 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
 
     RESTCatalog catalog = new RESTCatalog(context, (config) -> adapter);
     catalog.initialize(
-        "prod", ImmutableMap.of(CatalogProperties.URI, "ignored", "credential", credential));
+        "prod",
+        ImmutableMap.of(
+            CatalogProperties.URI,
+            "ignored",
+            "credential",
+            credential,
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Awaitility.await()
         .atMost(5, TimeUnit.SECONDS)
@@ -1739,7 +1882,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter)
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       any(),
                       eq(OAuthTokenResponse.class),
@@ -1762,7 +1905,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter, times(2))
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       Mockito.argThat(firstRefreshRequest::equals),
                       eq(OAuthTokenResponse.class),
@@ -1774,7 +1917,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter)
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       Mockito.argThat(firstRefreshRequest::equals),
                       eq(OAuthTokenResponse.class),
@@ -1798,8 +1941,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             });
   }
 
-  @Test
-  public void testCatalogWithCustomTokenScope() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogWithCustomTokenScope(String oauth2ServerUri) {
     Map<String, String> emptyHeaders = ImmutableMap.of();
     Map<String, String> catalogHeaders =
         ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=catalog");
@@ -1822,7 +1966,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
         .when(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -1844,7 +1988,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             "credential",
             "catalog:secret",
             OAuth2Properties.SCOPE,
-            scope));
+            scope,
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Awaitility.await()
         .atMost(5, TimeUnit.SECONDS)
@@ -1854,7 +2000,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter)
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       any(),
                       eq(OAuthTokenResponse.class),
@@ -1882,7 +2028,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
               Mockito.verify(adapter)
                   .execute(
                       eq(HTTPMethod.POST),
-                      eq("v1/oauth/tokens"),
+                      eq(oauth2ServerUri),
                       any(),
                       Mockito.argThat(firstRefreshRequest::equals),
                       eq(OAuthTokenResponse.class),
@@ -1891,8 +2037,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             });
   }
 
-  @Test
-  public void testCatalogTokenRefreshDisabledWithToken() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogTokenRefreshDisabledWithToken(String oauth2ServerUri) {
     String token = "some-token";
     Map<String, String> catalogHeaders = ImmutableMap.of("Authorization", "Bearer " + token);
 
@@ -1914,7 +2061,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
         .when(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             any(),
             eq(OAuthTokenResponse.class),
@@ -1935,7 +2082,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             OAuth2Properties.TOKEN,
             token,
             OAuth2Properties.TOKEN_REFRESH_ENABLED,
-            "false"));
+            "false",
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     Mockito.verify(adapter)
         .execute(
@@ -1948,8 +2097,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             any());
   }
 
-  @Test
-  public void testCatalogTokenRefreshDisabledWithCredential() {
+  @ParameterizedTest
+  @ValueSource(strings = {"v1/oauth/tokens", "https://auth-server.com/token"})
+  public void testCatalogTokenRefreshDisabledWithCredential(String oauth2ServerUri) {
     Map<String, String> catalogHeaders =
         ImmutableMap.of("Authorization", "Bearer client-credentials-token:sub=catalog");
 
@@ -1968,7 +2118,9 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
             OAuth2Properties.CREDENTIAL,
             "catalog:12345",
             OAuth2Properties.TOKEN_REFRESH_ENABLED,
-            "false"));
+            "false",
+            OAuth2Properties.OAUTH2_SERVER_URI,
+            oauth2ServerUri));
 
     // fetch token from client credential
     Map<String, String> fetchTokenFromCredential =
@@ -1984,7 +2136,7 @@ public class TestRESTCatalog extends CatalogTests<RESTCatalog> {
     Mockito.verify(adapter)
         .execute(
             eq(HTTPMethod.POST),
-            eq("v1/oauth/tokens"),
+            eq(oauth2ServerUri),
             any(),
             Mockito.argThat(fetchTokenFromCredential::equals),
             eq(OAuthTokenResponse.class),