From 6be123b18ea6c7658f97f674e659a5730cabcacb Mon Sep 17 00:00:00 2001 From: Alexandre Dutra Date: Thu, 11 Dec 2025 11:15:35 -0300 Subject: [PATCH] REST, OAuth2: OAuth2 AuthManager v2 - Part 1: Configuration This is the first PR related to the "Auth Manager v2" effort. Cf. [design document]. This PR introduces the configuration layer changes (section 5.1 of the design doc). It also introduces a few class deprecations. The most important part is the migration of configuration properties. This is handled by `ConfigMigrator`. Its logic may seem complex, that's because we need to handle some edge cases, as detailed in the design doc, section 5.6. The most complex edge case is when a session context initiates a token exchange flow, using the catalog session's parent token as the actor token (a.k.a. the "Trino use case"): this is handled in v2 by introducing a special sentinel value that will be recognized and processed at runtime (this will come in a later PR though). Merging this PR has only one visible consequence: deprecation warnings will be printed when loading the legacy (v1) manager, and when any deprecated v1 configuration is used. [design document]:https://docs.google.com/document/d/1Hxw-t8Maa7wZFmrlSujm7LRawKsFP3Q31tET_3aRnQU/edit --- build.gradle | 4 + .../apache/iceberg/rest/ErrorHandlers.java | 12 + .../apache/iceberg/rest/auth/AuthConfig.java | 4 + .../iceberg/rest/auth/AuthManagers.java | 11 +- .../iceberg/rest/auth/AuthProperties.java | 6 +- .../iceberg/rest/auth/AuthSessionCache.java | 8 +- .../iceberg/rest/auth/BasicAuthManager.java | 10 +- .../iceberg/rest/auth/OAuth2Manager.java | 5 + .../iceberg/rest/auth/OAuth2Properties.java | 5 + .../apache/iceberg/rest/auth/OAuth2Util.java | 5 + .../rest/auth/oauth2/OAuth2Config.java | 83 ++ .../rest/auth/oauth2/OAuth2Manager.java | 61 ++ .../rest/auth/oauth2/OAuth2Session.java | 43 + .../rest/auth/oauth2/config/BasicConfig.java | 284 ++++++ .../auth/oauth2/config/ConfigMigrator.java | 463 ++++++++++ .../rest/auth/oauth2/config/ConfigUtil.java | 105 +++ .../auth/oauth2/config/ConfigValidator.java | 95 ++ .../oauth2/config/TokenExchangeConfig.java | 118 +++ .../oauth2/config/TokenRefreshConfig.java | 123 +++ .../responses/OAuthErrorResponseParser.java | 5 + .../iceberg/rest/auth/TestAuthManagers.java | 64 +- .../rest/auth/oauth2/TestOAuth2Config.java | 87 ++ .../auth/oauth2/config/TestBasicConfig.java | 257 ++++++ .../oauth2/config/TestConfigMigrator.java | 848 ++++++++++++++++++ .../auth/oauth2/config/TestConfigUtil.java | 114 +++ .../config/TestTokenExchangeConfig.java | 95 ++ .../oauth2/config/TestTokenRefreshConfig.java | 88 ++ gradle/libs.versions.toml | 4 + 28 files changed, 2997 insertions(+), 10 deletions(-) create mode 100644 core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Config.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Manager.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Session.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/BasicConfig.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigMigrator.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigUtil.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigValidator.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/TokenExchangeConfig.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/TokenRefreshConfig.java create mode 100644 core/src/test/java/org/apache/iceberg/rest/auth/oauth2/TestOAuth2Config.java create mode 100644 core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestBasicConfig.java create mode 100644 core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestConfigMigrator.java create mode 100644 core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestConfigUtil.java create mode 100644 core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestTokenExchangeConfig.java create mode 100644 core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestTokenRefreshConfig.java diff --git a/build.gradle b/build.gradle index 52d25bc33b51..dfee22a5ad56 100644 --- a/build.gradle +++ b/build.gradle @@ -384,6 +384,10 @@ project(':iceberg-core') { exclude group: 'org.slf4j', module: 'slf4j-log4j12' } + // OAuth2 + implementation libs.nimbus.oauth2.oidc.sdk + implementation libs.nimbus.jose.jwt + testImplementation libs.jetty.servlet testImplementation libs.jakarta.servlet testImplementation libs.jetty.server diff --git a/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java b/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java index 791eb732bb7c..ebb216cfb9f7 100644 --- a/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java +++ b/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java @@ -92,6 +92,13 @@ public static Consumer defaultErrorHandler() { return DefaultErrorHandler.INSTANCE; } + /** + * The OAuth error handler. + * + * @deprecated will be removed in 1.14.0, use {@link + * org.apache.iceberg.rest.auth.oauth2.OAuth2Manager} instead. + */ + @Deprecated public static Consumer oauthErrorHandler() { return OAuthErrorHandler.INSTANCE; } @@ -339,6 +346,11 @@ public void accept(ErrorResponse error) { } } + /** + * @deprecated will be removed in 1.14.0, use {@link + * org.apache.iceberg.rest.auth.oauth2.OAuth2Manager} instead. + */ + @Deprecated private static class OAuthErrorHandler extends ErrorHandler { private static final ErrorHandler INSTANCE = new OAuthErrorHandler(); diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/AuthConfig.java b/core/src/main/java/org/apache/iceberg/rest/auth/AuthConfig.java index 16d781e43579..5e76458d8052 100644 --- a/core/src/main/java/org/apache/iceberg/rest/auth/AuthConfig.java +++ b/core/src/main/java/org/apache/iceberg/rest/auth/AuthConfig.java @@ -27,10 +27,14 @@ /** * The purpose of this class is to hold OAuth configuration options for {@link * OAuth2Util.AuthSession}. + * + * @deprecated will be removed in 1.14.0, use {@link + * org.apache.iceberg.rest.auth.oauth2.OAuth2Config} instead. */ @Value.Style(redactedMask = "****") @Value.Immutable @SuppressWarnings({"ImmutablesStyle", "SafeLoggingPropagation"}) +@Deprecated public interface AuthConfig { @Nullable @Value.Redacted diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/AuthManagers.java b/core/src/main/java/org/apache/iceberg/rest/auth/AuthManagers.java index 4e48118561f7..8f4814ce8536 100644 --- a/core/src/main/java/org/apache/iceberg/rest/auth/AuthManagers.java +++ b/core/src/main/java/org/apache/iceberg/rest/auth/AuthManagers.java @@ -105,7 +105,16 @@ public static AuthManager loadAuthManager(String name, Map prope impl = authType; } - LOG.info("Loading AuthManager implementation: {}", impl); + if (impl.equals(AuthProperties.AUTH_MANAGER_IMPL_OAUTH2_LEGACY)) { + LOG.warn( + "The AuthManager implementation {} is deprecated and will be removed in a future release. " + + "Please migrate to {}.", + AuthProperties.AUTH_MANAGER_IMPL_OAUTH2_LEGACY, + AuthProperties.AUTH_MANAGER_IMPL_OAUTH2_NEW); + } else { + LOG.info("Loading AuthManager implementation: {}", impl); + } + DynConstructors.Ctor ctor; try { ctor = diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/AuthProperties.java b/core/src/main/java/org/apache/iceberg/rest/auth/AuthProperties.java index 331c7a734a27..c287b5a54d09 100644 --- a/core/src/main/java/org/apache/iceberg/rest/auth/AuthProperties.java +++ b/core/src/main/java/org/apache/iceberg/rest/auth/AuthProperties.java @@ -34,8 +34,12 @@ private AuthProperties() {} "org.apache.iceberg.rest.auth.NoopAuthManager"; public static final String AUTH_MANAGER_IMPL_BASIC = "org.apache.iceberg.rest.auth.BasicAuthManager"; - public static final String AUTH_MANAGER_IMPL_OAUTH2 = + static final String AUTH_MANAGER_IMPL_OAUTH2_LEGACY = "org.apache.iceberg.rest.auth.OAuth2Manager"; + static final String AUTH_MANAGER_IMPL_OAUTH2_NEW = + "org.apache.iceberg.rest.auth.oauth2.OAuth2Manager"; + public static final String AUTH_MANAGER_IMPL_OAUTH2 = + AUTH_MANAGER_IMPL_OAUTH2_LEGACY; // TODO switch to new manager public static final String AUTH_MANAGER_IMPL_SIGV4 = "org.apache.iceberg.aws.RESTSigV4AuthManager"; public static final String AUTH_MANAGER_IMPL_GOOGLE = diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/AuthSessionCache.java b/core/src/main/java/org/apache/iceberg/rest/auth/AuthSessionCache.java index fe64244a8111..07bbc3c3105d 100644 --- a/core/src/main/java/org/apache/iceberg/rest/auth/AuthSessionCache.java +++ b/core/src/main/java/org/apache/iceberg/rest/auth/AuthSessionCache.java @@ -32,7 +32,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** A cache for {@link AuthSession} instances. */ +/** + * A cache for {@link AuthSession} instances. + * + * @deprecated will be removed in 1.14.0, use {@link + * org.apache.iceberg.rest.auth.oauth2.OAuth2Manager} instead. + */ +@Deprecated public class AuthSessionCache implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(AuthSessionCache.class); diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/BasicAuthManager.java b/core/src/main/java/org/apache/iceberg/rest/auth/BasicAuthManager.java index d0d56d3d3794..1725bcb42b30 100644 --- a/core/src/main/java/org/apache/iceberg/rest/auth/BasicAuthManager.java +++ b/core/src/main/java/org/apache/iceberg/rest/auth/BasicAuthManager.java @@ -18,8 +18,11 @@ */ package org.apache.iceberg.rest.auth; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Map; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.rest.HTTPHeaders; import org.apache.iceberg.rest.RESTClient; @@ -43,7 +46,12 @@ public AuthSession catalogSession(RESTClient sharedClient, Map p String username = properties.get(AuthProperties.BASIC_USERNAME); String password = properties.get(AuthProperties.BASIC_PASSWORD); String credentials = username + ":" + password; - return DefaultAuthSession.of(HTTPHeaders.of(OAuth2Util.basicAuthHeaders(credentials))); + // Note: RFC 7617 specifies ISO-8859-1 as the default encoding for Basic authentication + // credentials. This implementation uses UTF-8 for backwards compatibility. + String encoded = + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return DefaultAuthSession.of( + HTTPHeaders.of(ImmutableMap.of("Authorization", "Basic " + encoded))); } @Override diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Manager.java b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Manager.java index d9e314c5105e..5f84d212b6b3 100644 --- a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Manager.java +++ b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Manager.java @@ -40,6 +40,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * @deprecated will be removed in 1.14.0, use {@link + * org.apache.iceberg.rest.auth.oauth2.OAuth2Manager} instead. + */ +@Deprecated public class OAuth2Manager implements AuthManager { private static final Logger LOG = LoggerFactory.getLogger(OAuth2Manager.class); 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 cf9018ff6f95..ee18105d3c18 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 @@ -18,6 +18,11 @@ */ package org.apache.iceberg.rest.auth; +/** + * @deprecated will be removed in 1.14.0, use {@link + * org.apache.iceberg.rest.auth.oauth2.OAuth2Config} instead. + */ +@Deprecated public class OAuth2Properties { private OAuth2Properties() {} 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 c2b47e6e944f..8c608f71eec9 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 @@ -55,6 +55,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * @deprecated will be removed in 1.14.0, use {@link + * org.apache.iceberg.rest.auth.oauth2.OAuth2Manager} instead. + */ +@Deprecated public class OAuth2Util { private OAuth2Util() {} diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Config.java b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Config.java new file mode 100644 index 000000000000..9191e54b4f6b --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Config.java @@ -0,0 +1,83 @@ +/* + * 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 com.nimbusds.oauth2.sdk.GrantType; +import java.util.Map; +import org.apache.iceberg.rest.auth.oauth2.config.BasicConfig; +import org.apache.iceberg.rest.auth.oauth2.config.ConfigValidator; +import org.apache.iceberg.rest.auth.oauth2.config.ImmutableTokenExchangeConfig; +import org.apache.iceberg.rest.auth.oauth2.config.ImmutableTokenRefreshConfig; +import org.apache.iceberg.rest.auth.oauth2.config.TokenExchangeConfig; +import org.apache.iceberg.rest.auth.oauth2.config.TokenRefreshConfig; +import org.immutables.value.Value; + +/** The configuration for the OAuth2 AuthManager. */ +@Value.Immutable +public interface OAuth2Config { + + String PREFIX = "rest.auth.oauth2."; + + /** + * The basic configuration, including token endpoint, grant type, client id and client secret. + * Required. + */ + BasicConfig basicConfig(); + + /** The token refresh configuration. Optional. */ + @Value.Default + default TokenRefreshConfig tokenRefreshConfig() { + return ImmutableTokenRefreshConfig.builder().build(); + } + + /** + * The token exchange configuration. Required for the {@link GrantType#TOKEN_EXCHANGE} grant type. + */ + @Value.Default + default TokenExchangeConfig tokenExchangeConfig() { + return ImmutableTokenExchangeConfig.builder().build(); + } + + @Value.Check + default void validate() { + // At this level, we only need to validate constraints that span multiple + // configuration classes; individual configuration classes are validated + // internally in their respective validate() methods. + ConfigValidator validator = new ConfigValidator(); + + if (basicConfig().grantType().equals(GrantType.TOKEN_EXCHANGE)) { + validator.check( + tokenExchangeConfig().subjectTokenString().isPresent(), + TokenExchangeConfig.SUBJECT_TOKEN, + "subject token must be set if grant type is '%s'", + GrantType.TOKEN_EXCHANGE.getValue()); + } + + validator.validate(); + } + + /** Creates an {@link OAuth2Config} from the given properties map. */ + static OAuth2Config of(Map properties) { + return ImmutableOAuth2Config.builder() + .basicConfig(BasicConfig.parse(properties).build()) + .tokenRefreshConfig(TokenRefreshConfig.parse(properties).build()) + .tokenExchangeConfig(TokenExchangeConfig.parse(properties).build()) + .build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Manager.java b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Manager.java new file mode 100644 index 000000000000..95ad049d7734 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Manager.java @@ -0,0 +1,61 @@ +/* + * 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 java.util.Map; +import org.apache.iceberg.catalog.SessionCatalog.SessionContext; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.rest.RESTClient; +import org.apache.iceberg.rest.auth.AuthManager; +import org.apache.iceberg.rest.auth.AuthSession; + +public class OAuth2Manager implements AuthManager { + + public OAuth2Manager(String name) {} + + @Override + public AuthSession initSession(RESTClient initClient, Map initProperties) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public AuthSession catalogSession( + RESTClient sharedClient, Map catalogProperties) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public AuthSession contextualSession(SessionContext context, AuthSession parent) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public AuthSession tableSession( + TableIdentifier table, Map properties, AuthSession parent) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public AuthSession tableSession(RESTClient sharedClient, Map properties) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public void close() {} +} diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Session.java b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Session.java new file mode 100644 index 000000000000..649c899d253e --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/OAuth2Session.java @@ -0,0 +1,43 @@ +/* + * 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 org.apache.iceberg.rest.HTTPRequest; +import org.apache.iceberg.rest.auth.AuthSession; + +public class OAuth2Session implements AuthSession { + + private final OAuth2Config config; + + public OAuth2Session(OAuth2Config config) { + this.config = config; + } + + public OAuth2Config config() { + return config; + } + + @Override + public HTTPRequest authenticate(HTTPRequest request) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public void close() {} +} diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/BasicConfig.java b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/BasicConfig.java new file mode 100644 index 000000000000..6f55cd9bed7b --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/BasicConfig.java @@ -0,0 +1,284 @@ +/* + * 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.config; + +import com.nimbusds.oauth2.sdk.GrantType; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.token.AccessToken; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.apache.iceberg.rest.RESTUtil; +import org.apache.iceberg.rest.auth.oauth2.OAuth2Config; +import org.immutables.value.Value; + +/** + * Basic OAuth2 properties. These properties are used to configure the basic OAuth2 options such as + * the issuer URL, token endpoint, client ID, and client secret. + */ +@Value.Immutable +@Value.Style(redactedMask = "****") +@SuppressWarnings({"ImmutablesStyle", "SafeLoggingPropagation"}) +public interface BasicConfig { + + String PREFIX = OAuth2Config.PREFIX; + + String TOKEN = PREFIX + "token"; + String ISSUER_URL = PREFIX + "issuer-url"; + String TOKEN_ENDPOINT = PREFIX + "token-endpoint"; + String GRANT_TYPE = PREFIX + "grant-type"; + String CLIENT_ID = PREFIX + "client-id"; + String CLIENT_AUTH = PREFIX + "client-auth"; + String CLIENT_SECRET = PREFIX + "client-secret"; + String SCOPE = PREFIX + "scope"; + String EXTRA_PARAMS = PREFIX + "extra-params"; + String SESSION_CACHE_TIMEOUT = PREFIX + "session-cache-timeout"; + + GrantType DEFAULT_GRANT_TYPE = GrantType.CLIENT_CREDENTIALS; + ClientAuthenticationMethod DEFAULT_CLIENT_AUTH = ClientAuthenticationMethod.CLIENT_SECRET_BASIC; + Duration DEFAULT_SESSION_CACHE_TIMEOUT = Duration.parse("PT1H"); + + /** + * The initial access token to use. Optional. If this is set, the OAuth2 client will not attempt + * to fetch an initial token from the Authorization server, but will use this token instead. + * + *

This option should be avoided as in most cases, the token cannot be refreshed. + */ + @Value.Redacted + Optional token(); + + /** + * The root URL of the Authorization server, which will be used for discovering supported + * endpoints and their locations. For Keycloak, this is typically the realm URL: {@code + * https:///realms/}. + * + *

Two "well-known" paths are supported for endpoint discovery: {@code + * .well-known/openid-configuration} and {@code .well-known/oauth-authorization-server}. The full + * metadata discovery URL will be constructed by appending these paths to the issuer URL. + * + *

Unless a {@linkplain #TOKEN static token} is provided, either this property or {@link + * #TOKEN_ENDPOINT} must be set. + * + * @see OpenID + * Connect Discovery 1.0 + * @see RFC 8414 Section 5 + */ + Optional issuerUrl(); + + /** + * URL of the OAuth2 token endpoint. For Keycloak, this is typically {@code + * https:///realms//protocol/openid-connect/token}. + * + *

Unless a {@linkplain #TOKEN static token} is provided, either this property or {@link + * #ISSUER_URL} must be set. In case it is not set, the token endpoint will be discovered from the + * {@link #ISSUER_URL issuer URL}, using the OpenID Connect Discovery metadata published by the + * issuer. + */ + Optional tokenEndpoint(); + + /** + * The grant type to use when authenticating against the OAuth2 server. Valid values are: + * + *

    + *
  • {@link GrantType#CLIENT_CREDENTIALS client_credentials} + *
  • {@link GrantType#TOKEN_EXCHANGE urn:ietf:params:oauth:grant-type:token-exchange} + *
+ * + * Optional, defaults to {@link #DEFAULT_GRANT_TYPE}. + */ + @Value.Default + default GrantType grantType() { + return DEFAULT_GRANT_TYPE; + } + + /** + * Client ID to use when authenticating against the OAuth2 server. Required, unless a {@linkplain + * #TOKEN static token} is provided. + */ + Optional clientId(); + + /** + * The OAuth2 client authentication method to use. Valid values are: + * + *
    + *
  • {@link ClientAuthenticationMethod#NONE none}: the client does not authenticate itself at + * the token endpoint, because it is a public client with no client secret or other + * authentication mechanism. + *
  • {@link ClientAuthenticationMethod#CLIENT_SECRET_BASIC client_secret_basic}: client secret + * is sent in the HTTP Basic Authorization header. + *
  • {@link ClientAuthenticationMethod#CLIENT_SECRET_POST client_secret_post}: client secret + * is sent in the request body as a form parameter. + *
+ * + * The default is {@link #DEFAULT_CLIENT_AUTH}. + */ + @Value.Default + default ClientAuthenticationMethod clientAuthenticationMethod() { + return DEFAULT_CLIENT_AUTH; + } + + /** + * Client secret to use when authenticating against the OAuth2 server. Required if the client is + * private and is authenticated using the standard "client-secret" methods. + */ + @Value.Redacted + Optional clientSecret(); + + /** + * Space-separated list of scopes to include in each request to the OAuth2 server. Optional, + * defaults to empty (no scopes). + * + *

The scope names will not be validated by the OAuth2 client; make sure they are valid + * according to RFC 6749 + * Section 3.3. + */ + Optional scope(); + + /** + * Extra parameters to include in each request to the token endpoint. This is useful for custom + * parameters that are not covered by the standard OAuth2 specification. Optional, defaults to + * empty. + * + *

This is a prefix property, and multiple values can be set, each with a different key and + * value. The values must NOT be URL-encoded. Example: + * + *

{@code
+   * rest.auth.oauth2.extra-params.custom_param1=custom_value1
+   * rest.auth.oauth2.extra-params.custom_param2=custom_value2
+   * }
+ * + * For example, Auth0 requires the {@code audience} parameter to be set to the API identifier. + * This can be done by setting the following configuration: + * + *
{@code
+   * rest.auth.oauth2.extra-params.audience=https://iceberg-rest-catalog/api
+   * }
+ */ + Map extraRequestParameters(); + + /** + * The session cache timeout. Cached sessions will become eligible for eviction after this + * duration of inactivity. Defaults to 1 hour. Must be a valid ISO-8601 duration. + * + *

This value is used for housekeeping; it does not mean that cached sessions will stop working + * after this time, but that the session cache will evict the session after this time of + * inactivity. If the context is used again, a new session will be created and cached. + * + *

This property can only be specified at catalog session level. It is ignored if present in + * other levels. + */ + @Value.Default + default Duration sessionCacheTimeout() { + return DEFAULT_SESSION_CACHE_TIMEOUT; + } + + @Value.Check + default void validate() { + ConfigValidator validator = new ConfigValidator(); + + if (token().isEmpty()) { + + validator.check( + ConfigUtil.SUPPORTED_INITIAL_GRANT_TYPES.contains(grantType()), + GRANT_TYPE, + "grant type must be one of: %s", + ConfigUtil.SUPPORTED_INITIAL_GRANT_TYPES.stream() + .map(GrantType::getValue) + .collect(Collectors.joining("', '", "'", "'"))); + + validator.check( + ConfigUtil.SUPPORTED_CLIENT_AUTH_METHODS.contains(clientAuthenticationMethod()), + CLIENT_AUTH, + "client authentication method must be one of: %s", + ConfigUtil.SUPPORTED_CLIENT_AUTH_METHODS.stream() + .map(ClientAuthenticationMethod::getValue) + .collect(Collectors.joining("', '", "'", "'"))); + + validator.check( + issuerUrl().isPresent() || tokenEndpoint().isPresent(), + List.of(ISSUER_URL, TOKEN_ENDPOINT), + "either issuer URL or token endpoint must be set"); + + validator.check(clientId().isPresent(), CLIENT_ID, "client ID must not be empty"); + + if (ConfigUtil.requiresClientSecret(clientAuthenticationMethod())) { + validator.check( + clientSecret().isPresent(), + List.of(CLIENT_AUTH, CLIENT_SECRET), + "client secret must not be empty when client authentication is '%s'", + clientAuthenticationMethod().getValue()); + } else if (clientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) { + validator.check( + clientSecret().isEmpty(), + List.of(CLIENT_AUTH, CLIENT_SECRET), + "client secret must not be set when client authentication is '%s'", + ClientAuthenticationMethod.NONE.getValue()); + validator.check( + !grantType().equals(DEFAULT_GRANT_TYPE), + List.of(CLIENT_AUTH, GRANT_TYPE), + "grant type must not be '%s' when client authentication is '%s'", + DEFAULT_GRANT_TYPE.getValue(), + ClientAuthenticationMethod.NONE.getValue()); + } + } + + if (issuerUrl().isPresent()) { + validator.checkEndpoint(issuerUrl().get(), ISSUER_URL, "Issuer URL"); + } + + if (tokenEndpoint().isPresent()) { + validator.checkEndpoint(tokenEndpoint().get(), TOKEN_ENDPOINT, "Token endpoint"); + } + + validator.validate(); + } + + static ImmutableBasicConfig.Builder parse(Map properties) { + List scopes = ConfigUtil.parseList(properties, SCOPE, " "); + return ImmutableBasicConfig.builder() + .token(ConfigUtil.parseOptional(properties, TOKEN, BearerAccessToken::new)) + .issuerUrl(ConfigUtil.parseOptional(properties, ISSUER_URL, URI::create)) + .tokenEndpoint(ConfigUtil.parseOptional(properties, TOKEN_ENDPOINT, URI::create)) + .grantType( + ConfigUtil.parseOptional(properties, GRANT_TYPE, GrantType::parse) + .orElse(DEFAULT_GRANT_TYPE)) + .clientAuthenticationMethod( + ConfigUtil.parseOptional(properties, CLIENT_AUTH, ClientAuthenticationMethod::parse) + .orElse(DEFAULT_CLIENT_AUTH)) + .clientId(ConfigUtil.parseOptional(properties, CLIENT_ID, ClientID::new)) + .clientSecret(ConfigUtil.parseOptional(properties, CLIENT_SECRET, Secret::new)) + .scope( + scopes.isEmpty() + ? Optional.empty() + : Optional.of(new Scope(scopes.toArray(String[]::new)))) + .extraRequestParameters(RESTUtil.extractPrefixMap(properties, EXTRA_PARAMS + '.')) + .sessionCacheTimeout( + ConfigUtil.parseOptional(properties, SESSION_CACHE_TIMEOUT, Duration::parse) + .orElse(DEFAULT_SESSION_CACHE_TIMEOUT)); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigMigrator.java b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigMigrator.java new file mode 100644 index 000000000000..03777e40d6b8 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigMigrator.java @@ -0,0 +1,463 @@ +/* + * 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.config; + +import com.nimbusds.oauth2.sdk.GrantType; +import com.nimbusds.oauth2.sdk.id.Audience; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.token.TokenTypeURI; +import com.nimbusds.oauth2.sdk.token.TypelessAccessToken; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.rest.RESTUtil; +import org.apache.iceberg.rest.ResourcePaths; +import org.apache.iceberg.rest.auth.OAuth2Properties; +import org.apache.iceberg.rest.auth.oauth2.ImmutableOAuth2Config; +import org.apache.iceberg.rest.auth.oauth2.OAuth2Config; +import org.apache.iceberg.util.PropertyUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A component for migrating from legacy OAuth2 properties (from {@link OAuth2Properties}) to the + * new OAuth2 properties (as declared in {@link OAuth2Config}). + */ +@SuppressWarnings("deprecation") +public final class ConfigMigrator { + + /** + * The default client ID to use when no client ID is provided in the legacy {@link + * OAuth2Properties#CREDENTIAL} property. + */ + public static final ClientID DEFAULT_CLIENT_ID = new ClientID("iceberg"); + + private static final Logger LOGGER = LoggerFactory.getLogger(ConfigMigrator.class); + + private static final Splitter CREDENTIAL_SPLITTER = Splitter.on(":").limit(2).trimResults(); + + private static final Set TABLE_CONFIG_ALLOW_LIST = + Set.of( + BasicConfig.TOKEN, + TokenExchangeConfig.SUBJECT_TOKEN, + TokenExchangeConfig.SUBJECT_TOKEN_TYPE, + TokenExchangeConfig.ACTOR_TOKEN, + TokenExchangeConfig.ACTOR_TOKEN_TYPE); + + @VisibleForTesting + static final String MESSAGE_TEMPLATE_LEGACY_OPTION = + "Detected legacy OAuth2 property '{}', please use option{} {} instead."; + + @VisibleForTesting + static final String MESSAGE_TEMPLATE_NO_CLIENT_ID = + "The legacy OAuth2 property 'credential' was provided, but it did not contain a client ID; assuming '{}'."; + + @VisibleForTesting + static final String MESSAGE_TEMPLATE_MISSING_TOKEN_ENDPOINT = + "The OAuth2 configuration does not specify a token endpoint nor an issuer URL: " + + "the token endpoint URL will default to {}. " + + "This automatic fallback will be removed in a future Iceberg release. " + + "Please configure OAuth2 endpoints using the following properties: '{}' or '{}'. " + + "This warning will disappear if OAuth2 endpoints are properly configured. " + + "See https://github.com/apache/iceberg/issues/10537"; + + @VisibleForTesting + static final String MESSAGE_TEMPLATE_RELATIVE_TOKEN_ENDPOINT = + "The OAuth2 token endpoint URL is a relative URL. " + + "It will be resolved against the catalog URI, resulting in the absolute URL: '{}'. " + + "This automatic fallback will be removed in a future Iceberg release. " + + "Please configure OAuth2 endpoints using absolute URLs."; + + @VisibleForTesting + static final String MESSAGE_TEMPLATE_MERGED_CONTEXTUAL_CONFIG = + "The OAuth2 configuration property '{}' was not found in the context session, " + + "and will be inherited from the parent session. " + + "This automatic fallback will be removed in a future Iceberg release."; + + @VisibleForTesting + static final String MESSAGE_TEMPLATE_VENDED_TOKEN = + "The OAuth2 configuration property '{}' was found in the table configuration " + + "and indicates that the catalog server vended an OAuth2 token. " + + "Vended OAuth2 tokens will be disallowed in a future Iceberg release."; + + @VisibleForTesting + static final String MESSAGE_TEMPLATE_TABLE_CONFIG_NOT_ALLOWED = + "The OAuth2 configuration property '{}' is not allowed to be vended by catalog servers."; + + private final BiConsumer logConsumer; + + public ConfigMigrator() { + this(LOGGER); + } + + public ConfigMigrator(Logger logger) { + this(logger::warn); + } + + @VisibleForTesting + ConfigMigrator(BiConsumer logConsumer) { + this.logConsumer = logConsumer; + } + + /** + * Migrates catalog-level properties. + * + *

Legacy Iceberg OAuth2 properties are migrated, and warnings are logged for each detected + * legacy property. + * + *

The migration will further check the token endpoint for the following legacy situations: + * + *

    + *
  1. If no token endpoint is provided, a default one will be added to the migrated properties; + * its value is the catalog URI + {@link ResourcePaths#tokens()} – that is, it will point to + * the (deprecated) REST Catalog token endpoint. + *
  2. If a token endpoint is provided, but is a relative path, it will be resolved against the + * catalog URI. + *
+ * + * In both cases, a warning will be logged. + * + * @param properties The properties to migrate + * @param catalogUri The catalog URI, for extended token endpoint checks + */ + public OAuth2Config migrateCatalogConfig(Map properties, String catalogUri) { + Map migrated = migrateProperties(properties); + handleTokenEndpoint(migrated, catalogUri); + return OAuth2Config.of(migrated); + } + + /** + * Migrates session context properties. + * + *

Legacy Iceberg OAuth2 properties are migrated, and warnings are logged for each detected + * legacy property. + * + *

See {@link #migrateCatalogConfig(Map, String)} for details on token endpoint checks. + * + *

Contextual configs inherit some properties from their parent config. This is legacy + * behavior, and will be removed after 1.13; a warning will be logged when this happens. + * + * @param parent The parent config + * @param properties The properties to migrate + * @param catalogUri The catalog URI, for extended token endpoint checks + */ + @SuppressWarnings("CyclomaticComplexity") + public OAuth2Config migrateContextualConfig( + OAuth2Config parent, Map properties, String catalogUri) { + Map migrated = migrateProperties(properties); + + if (!migrated.containsKey(BasicConfig.CLIENT_ID) + && parent.basicConfig().clientId().isPresent()) { + warnOnMergedContextualConfig(BasicConfig.CLIENT_ID); + migrated.put(BasicConfig.CLIENT_ID, parent.basicConfig().clientId().get().getValue()); + } + + if (!migrated.containsKey(BasicConfig.CLIENT_SECRET) + && !migrated.containsKey(BasicConfig.CLIENT_AUTH) + && parent.basicConfig().clientSecret().isPresent()) { + warnOnMergedContextualConfig(BasicConfig.CLIENT_SECRET); + migrated.put(BasicConfig.CLIENT_SECRET, parent.basicConfig().clientSecret().get().getValue()); + } + + if (!migrated.containsKey(BasicConfig.TOKEN_ENDPOINT) + && !migrated.containsKey(BasicConfig.ISSUER_URL) + && parent.basicConfig().tokenEndpoint().isPresent()) { + warnOnMergedContextualConfig(BasicConfig.TOKEN_ENDPOINT); + migrated.put( + BasicConfig.TOKEN_ENDPOINT, parent.basicConfig().tokenEndpoint().get().toString()); + } + + if (!migrated.containsKey(BasicConfig.SCOPE) && parent.basicConfig().scope().isPresent()) { + warnOnMergedContextualConfig(BasicConfig.SCOPE); + migrated.put(BasicConfig.SCOPE, parent.basicConfig().scope().get().toString()); + } + + if (!migrated.containsKey(TokenExchangeConfig.RESOURCES) + && !parent.tokenExchangeConfig().resources().isEmpty()) { + warnOnMergedContextualConfig(TokenExchangeConfig.RESOURCES); + migrated.put( + TokenExchangeConfig.RESOURCES, + parent.tokenExchangeConfig().resources().stream() + .map(URI::toString) + .collect(Collectors.joining(","))); + } + + if (!migrated.containsKey(TokenExchangeConfig.AUDIENCES) + && !parent.tokenExchangeConfig().audiences().isEmpty()) { + warnOnMergedContextualConfig(TokenExchangeConfig.AUDIENCES); + migrated.put( + TokenExchangeConfig.AUDIENCES, + parent.tokenExchangeConfig().audiences().stream() + .map(Audience::getValue) + .collect(Collectors.joining(","))); + } + + if (migrated.isEmpty()) { + return parent; + } + + handleTokenEndpoint(migrated, catalogUri); + + return OAuth2Config.of(migrated); + } + + /** + * Migrates table properties. + * + *

Legacy Iceberg OAuth2 properties are migrated, and warnings are logged for each detected + * legacy property. + * + *

Table configs are not allowed to contain any property that is not in the allow list; a + * warning will be logged for each detected disallowed property. + * + *

Moreover, table configs are considered deprecated, and a warning will be logged on any + * OAuth2 property found, even if it is allowed. + * + * @param parent The parent config + * @param properties The properties to migrate + */ + public OAuth2Config migrateTableConfig(OAuth2Config parent, Map properties) { + + Map migrated = migrateProperties(properties); + Map filtered = Maps.newHashMap(); + + for (Entry entry : migrated.entrySet()) { + if (TABLE_CONFIG_ALLOW_LIST.contains(entry.getKey())) { + warnOnVendedToken(entry.getKey()); + filtered.put(entry.getKey(), entry.getValue()); + } else { + warnOnForbiddenTableConfig(entry.getKey()); + } + } + + ImmutableBasicConfig.Builder basicBuilder = + ImmutableBasicConfig.builder().from(parent.basicConfig()); + ImmutableTokenExchangeConfig.Builder tokenExchangeBuilder = + ImmutableTokenExchangeConfig.builder().from(parent.tokenExchangeConfig()); + + if (filtered.containsKey(BasicConfig.TOKEN)) { + // static vended token use case + basicBuilder.token( + ConfigUtil.parseOptional(filtered, BasicConfig.TOKEN, TypelessAccessToken::new)); + } else { + // vended token exchange use cases + if (filtered.containsKey(TokenExchangeConfig.SUBJECT_TOKEN)) { + basicBuilder.grantType(GrantType.TOKEN_EXCHANGE); + tokenExchangeBuilder.subjectTokenString( + ConfigUtil.parseOptional(filtered, TokenExchangeConfig.SUBJECT_TOKEN)); + } + + if (filtered.containsKey(TokenExchangeConfig.SUBJECT_TOKEN_TYPE)) { + tokenExchangeBuilder.subjectTokenType( + ConfigUtil.parseOptional( + filtered, TokenExchangeConfig.SUBJECT_TOKEN_TYPE, TokenTypeURI::parse) + .orElse(TokenTypeURI.ACCESS_TOKEN)); + } + + if (filtered.containsKey(TokenExchangeConfig.ACTOR_TOKEN)) { + tokenExchangeBuilder.actorTokenString( + ConfigUtil.parseOptional(filtered, TokenExchangeConfig.ACTOR_TOKEN)); + } + + if (filtered.containsKey(TokenExchangeConfig.ACTOR_TOKEN_TYPE)) { + tokenExchangeBuilder.actorTokenType( + ConfigUtil.parseOptional( + filtered, TokenExchangeConfig.ACTOR_TOKEN_TYPE, TokenTypeURI::parse) + .orElse(TokenTypeURI.ACCESS_TOKEN)); + } + } + + return ImmutableOAuth2Config.builder() + .from(parent) + .basicConfig(basicBuilder.build()) + .tokenExchangeConfig(tokenExchangeBuilder.build()) + .build(); + } + + @VisibleForTesting + Map migrateProperties(Map properties) { + Map migrated = Maps.newLinkedHashMap(); + + // migrate legacy properties + for (Entry entry : properties.entrySet()) { + switch (entry.getKey()) { + case OAuth2Properties.TOKEN: + warnOnLegacyOption(entry.getKey(), BasicConfig.TOKEN); + migrated.put(BasicConfig.TOKEN, entry.getValue()); + break; + case OAuth2Properties.CREDENTIAL: + warnOnLegacyOption( + entry.getKey(), true, BasicConfig.CLIENT_ID, BasicConfig.CLIENT_SECRET); + List parts = CREDENTIAL_SPLITTER.splitToList(entry.getValue()); + if (parts.size() == 2) { + migrated.put(BasicConfig.CLIENT_ID, parts.get(0)); + migrated.put(BasicConfig.CLIENT_SECRET, parts.get(1)); + } else { + logConsumer.accept( + MESSAGE_TEMPLATE_NO_CLIENT_ID, new String[] {DEFAULT_CLIENT_ID.getValue()}); + migrated.put(BasicConfig.CLIENT_ID, DEFAULT_CLIENT_ID.getValue()); + migrated.put(BasicConfig.CLIENT_SECRET, parts.get(0)); + } + + break; + case OAuth2Properties.TOKEN_EXPIRES_IN_MS: + warnOnLegacyOption(entry.getKey(), TokenRefreshConfig.ACCESS_TOKEN_LIFESPAN); + Duration duration = + Duration.ofMillis( + PropertyUtil.propertyAsLong( + properties, + OAuth2Properties.TOKEN_EXPIRES_IN_MS, + OAuth2Properties.TOKEN_EXPIRES_IN_MS_DEFAULT)); + migrated.put(TokenRefreshConfig.ACCESS_TOKEN_LIFESPAN, duration.toString()); + break; + case OAuth2Properties.TOKEN_REFRESH_ENABLED: + warnOnLegacyOption(entry.getKey(), TokenRefreshConfig.ENABLED); + migrated.put( + TokenRefreshConfig.ENABLED, String.valueOf(Boolean.parseBoolean(entry.getValue()))); + break; + case OAuth2Properties.OAUTH2_SERVER_URI: + warnOnLegacyOption( + entry.getKey(), false, BasicConfig.ISSUER_URL, BasicConfig.TOKEN_ENDPOINT); + migrated.put(BasicConfig.TOKEN_ENDPOINT, entry.getValue()); + break; + case OAuth2Properties.SCOPE: + warnOnLegacyOption(entry.getKey(), BasicConfig.SCOPE); + migrated.put(BasicConfig.SCOPE, entry.getValue()); + break; + case OAuth2Properties.AUDIENCE: + warnOnLegacyOption(entry.getKey(), TokenExchangeConfig.AUDIENCES); + migrated.put(TokenExchangeConfig.AUDIENCES, entry.getValue()); + break; + case OAuth2Properties.RESOURCE: + warnOnLegacyOption(entry.getKey(), TokenExchangeConfig.RESOURCES); + migrated.put(TokenExchangeConfig.RESOURCES, entry.getValue()); + break; + case OAuth2Properties.ACCESS_TOKEN_TYPE: + case OAuth2Properties.ID_TOKEN_TYPE: + case OAuth2Properties.SAML1_TOKEN_TYPE: + case OAuth2Properties.SAML2_TOKEN_TYPE: + case OAuth2Properties.JWT_TOKEN_TYPE: + case OAuth2Properties.REFRESH_TOKEN_TYPE: + warnOnLegacyOption( + entry.getKey(), + true, + TokenExchangeConfig.SUBJECT_TOKEN, + TokenExchangeConfig.SUBJECT_TOKEN_TYPE, + TokenExchangeConfig.ACTOR_TOKEN); + migrated.put(BasicConfig.GRANT_TYPE, GrantType.TOKEN_EXCHANGE.getValue()); + migrated.put(TokenExchangeConfig.SUBJECT_TOKEN, entry.getValue()); + migrated.put(TokenExchangeConfig.SUBJECT_TOKEN_TYPE, entry.getKey()); + migrated.put(TokenExchangeConfig.ACTOR_TOKEN, ConfigUtil.PARENT_TOKEN); + break; + case OAuth2Properties.TOKEN_EXCHANGE_ENABLED: + warnOnLegacyOption(entry.getKey(), TokenRefreshConfig.TOKEN_EXCHANGE_ENABLED); + migrated.put(TokenRefreshConfig.TOKEN_EXCHANGE_ENABLED, entry.getValue()); + break; + } + } + + // preserve new properties, overriding legacy properties if any + for (Entry entry : properties.entrySet()) { + if (entry.getKey().startsWith(OAuth2Config.PREFIX)) { + migrated.put(entry.getKey(), entry.getValue()); + } + } + + return migrated; + } + + @VisibleForTesting + void handleTokenEndpoint(Map migrated, String catalogUri) { + + Preconditions.checkNotNull(catalogUri, "Catalog URI is required"); + + String tokenEndpoint = migrated.get(BasicConfig.TOKEN_ENDPOINT); + String issuerUrl = migrated.get(BasicConfig.ISSUER_URL); + String token = migrated.get(BasicConfig.TOKEN); + + if (tokenEndpoint == null && issuerUrl == null && token == null) { + + // No token endpoint or issuer URL configured, and no static token: + // default the token endpoint to catalog URI + ResourcePaths.tokens() + tokenEndpoint = RESTUtil.resolveEndpoint(catalogUri, ResourcePaths.tokens()); + migrated.put(BasicConfig.TOKEN_ENDPOINT, tokenEndpoint); + warnOnMissingTokenEndpoint(tokenEndpoint); + + } else if (tokenEndpoint != null && !URI.create(tokenEndpoint).isAbsolute()) { + + // If the token endpoint was provided, but is a relative path: + // assume it's an endpoint internal to the catalog server + // and resolve it against the catalog URI + tokenEndpoint = RESTUtil.resolveEndpoint(catalogUri, tokenEndpoint); + migrated.put(BasicConfig.TOKEN_ENDPOINT, tokenEndpoint); + warnOnRelativeTokenEndpoint(tokenEndpoint); + } + } + + private void warnOnLegacyOption(String icebergOption, String authManagerOption) { + warnOnLegacyOption(icebergOption, false, authManagerOption); + } + + private void warnOnLegacyOption(String legacyOption, boolean and, String... newOptions) { + List options = Lists.newArrayList(newOptions); + String joined = + options.size() == 1 + ? options.get(0) + : options.stream().limit(options.size() - 1).collect(Collectors.joining(", ")) + + (and ? " and " : " or ") + + options.get(options.size() - 1); + logConsumer.accept( + MESSAGE_TEMPLATE_LEGACY_OPTION, + new String[] {legacyOption, options.size() == 1 ? "" : "s", joined}); + } + + private void warnOnMissingTokenEndpoint(String tokenEndpoint) { + logConsumer.accept( + MESSAGE_TEMPLATE_MISSING_TOKEN_ENDPOINT, + new String[] { + tokenEndpoint, BasicConfig.TOKEN_ENDPOINT, BasicConfig.ISSUER_URL, + }); + } + + private void warnOnRelativeTokenEndpoint(String tokenEndpoint) { + logConsumer.accept(MESSAGE_TEMPLATE_RELATIVE_TOKEN_ENDPOINT, new String[] {tokenEndpoint}); + } + + private void warnOnMergedContextualConfig(String mergedOption) { + logConsumer.accept(MESSAGE_TEMPLATE_MERGED_CONTEXTUAL_CONFIG, new String[] {mergedOption}); + } + + private void warnOnVendedToken(String vendedOption) { + logConsumer.accept(MESSAGE_TEMPLATE_VENDED_TOKEN, new String[] {vendedOption}); + } + + private void warnOnForbiddenTableConfig(String tableOption) { + logConsumer.accept(MESSAGE_TEMPLATE_TABLE_CONFIG_NOT_ALLOWED, new String[] {tableOption}); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigUtil.java b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigUtil.java new file mode 100644 index 000000000000..abb1dea4aea3 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigUtil.java @@ -0,0 +1,105 @@ +/* + * 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.config; + +import com.nimbusds.oauth2.sdk.GrantType; +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; + +/** + * Helper class for parsing configuration options. It also exposes useful constants for config + * validation. + */ +public final class ConfigUtil { + + public static final List SUPPORTED_INITIAL_GRANT_TYPES = + List.of(GrantType.CLIENT_CREDENTIALS, GrantType.TOKEN_EXCHANGE); + + public static final List SUPPORTED_CLIENT_AUTH_METHODS = + List.of( + ClientAuthenticationMethod.NONE, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, + ClientAuthenticationMethod.CLIENT_SECRET_POST); + + /** + * A sentinel value used to indicate that the parent session's token should be used. This is + * useful for the token exchange flow. + */ + public static final String PARENT_TOKEN = "::parent::"; + + public static boolean requiresClientSecret(@Nullable ClientAuthenticationMethod method) { + return Objects.equals(method, ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + || Objects.equals(method, ClientAuthenticationMethod.CLIENT_SECRET_POST); + } + + static Optional parseOptional(Map properties, String option) { + return parseOptional(properties, option, s -> s); + } + + static Optional parseOptional( + Map properties, String option, ConfigParser parser) { + return Optional.ofNullable(properties.get(option)).map(parser::parseUnchecked); + } + + static OptionalInt parseOptionalInt(Map properties, String option) { + ConfigParser parser = Integer::parseInt; + return Optional.ofNullable(properties.get(option)) + .map(parser::parseUnchecked) + .map(OptionalInt::of) + .orElseGet(OptionalInt::empty); + } + + static List parseList(Map properties, String option, String delimiter) { + return parseList(properties, option, delimiter, s -> s); + } + + static List parseList( + Map properties, String option, String delimiter, ConfigParser parser) { + return Optional.ofNullable(properties.get(option)) + .map(s -> Splitter.on(delimiter).trimResults().omitEmptyStrings().splitToStream(s)) + .orElseGet(Stream::empty) + .map(parser::parseUnchecked) + .collect(Collectors.toList()); + } + + @FunctionalInterface + interface ConfigParser { + + T parse(String value) throws Exception; + + default T parseUnchecked(String value) { + try { + return parse(value); + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to parse configuration value '%s'".formatted(value), e); + } + } + } + + private ConfigUtil() {} +} diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigValidator.java b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigValidator.java new file mode 100644 index 000000000000..e05157b2138c --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/ConfigValidator.java @@ -0,0 +1,95 @@ +/* + * 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.config; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.net.URI; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.immutables.value.Value; + +public final class ConfigValidator { + + @Value.Immutable + interface ConfigViolation { + + @Value.Parameter(order = 1) + List offendingKeys(); + + @Value.Parameter(order = 2) + String message(); + + @Value.Lazy + default String formattedMessage() { + return message() + " (" + String.join(" / ", offendingKeys()) + ")"; + } + } + + private final List violations = Lists.newArrayList(); + + @FormatMethod + public void check( + boolean condition, String offendingKey, @FormatString String msg, Object... args) { + check(condition, List.of(offendingKey), msg, args); + } + + @FormatMethod + public void check( + boolean condition, List offendingKeys, @FormatString String msg, Object... args) { + if (!condition) { + violations.add(ImmutableConfigViolation.of(offendingKeys, String.format(msg, args))); + } + } + + public void checkEndpoint(URI endpoint, String offendingKey, String name) { + check(endpoint.isAbsolute(), offendingKey, "%s %s", name, "must not be relative"); + check( + endpoint.getUserInfo() == null, + offendingKey, + "%s %s", + name, + "must not have a user info part"); + check(endpoint.getQuery() == null, offendingKey, "%s %s", name, "must not have a query part"); + check( + endpoint.getFragment() == null, + offendingKey, + "%s %s", + name, + "must not have a fragment part"); + } + + public void validate() { + if (!violations.isEmpty()) { + throw new IllegalArgumentException( + buildDescription(violations.stream().map(ConfigViolation::formattedMessage))); + } + } + + private static final String DELIMITER = "\n - "; + + @VisibleForTesting + static String buildDescription(Stream violations) { + return "Invalid OAuth2 configuration:" + + violations.collect(Collectors.joining(DELIMITER, DELIMITER, "")); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/TokenExchangeConfig.java b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/TokenExchangeConfig.java new file mode 100644 index 000000000000..1dd5d6b62538 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/TokenExchangeConfig.java @@ -0,0 +1,118 @@ +/* + * 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.config; + +import com.nimbusds.oauth2.sdk.id.Audience; +import com.nimbusds.oauth2.sdk.token.TokenTypeURI; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.iceberg.rest.auth.oauth2.OAuth2Config; +import org.immutables.value.Value; + +/** + * Configuration properties for the Token + * Exchange flow. + * + *

This flow allows a client to exchange one token for another, typically to obtain a token that + * is more suitable for the target resource or service. + */ +@Value.Immutable +@Value.Style(redactedMask = "****") +@SuppressWarnings({"ImmutablesStyle", "SafeLoggingPropagation"}) +public interface TokenExchangeConfig { + + String PREFIX = OAuth2Config.PREFIX + "token-exchange."; + + String SUBJECT_TOKEN = PREFIX + "subject-token"; + String SUBJECT_TOKEN_TYPE = PREFIX + "subject-token-type"; + String ACTOR_TOKEN = PREFIX + "actor-token"; + String ACTOR_TOKEN_TYPE = PREFIX + "actor-token-type"; + String REQUESTED_TOKEN_TYPE = PREFIX + "requested-token-type"; + String RESOURCES = PREFIX + "resources"; + String AUDIENCES = PREFIX + "audiences"; + + /** + * The subject token to exchange. Required. + * + *

The special value {@code ::parent::} can be used to indicate that the subject token should + * be obtained from the parent OAuth2 session. + */ + @Value.Redacted + Optional subjectTokenString(); + + /** + * The type of the subject token. Must be a valid URN. Required. If not set, the default is {@code + * urn:ietf:params:oauth:token-type:access_token}. + * + * @see TokenExchangeConfig#SUBJECT_TOKEN_TYPE + */ + Optional subjectTokenType(); + + /** + * The actor token to exchange. Optional. + * + *

The special value {@code ::parent::} can be used to indicate that the actor token should be + * obtained from the parent OAuth2 session. + */ + @Value.Redacted + Optional actorTokenString(); + + /** + * The type of the actor token. Must be a valid URN. Required if an actor token is used. If not + * set, the default is {@code urn:ietf:params:oauth:token-type:access_token}. + * + * @see TokenExchangeConfig#ACTOR_TOKEN_TYPE + */ + Optional actorTokenType(); + + /** The type of the requested token. Must be a valid URN. Optional. */ + Optional requestedTokenType(); + + /** + * One or more URIs that indicate the target service(s) or resource(s) where the client intends to + * use the requested token. + * + *

Optional. Can be a single value or a comma-separated list of values. + */ + List resources(); + + /** + * The logical name(s) of the target service where the client intends to use the requested token. + * This serves a purpose similar to the resource parameter but with the client providing a logical + * name for the target service. + * + *

Optional. Can be a single value or a comma-separated list of values. + */ + List audiences(); + + static ImmutableTokenExchangeConfig.Builder parse(Map properties) { + return ImmutableTokenExchangeConfig.builder() + .subjectTokenString(ConfigUtil.parseOptional(properties, SUBJECT_TOKEN)) + .subjectTokenType( + ConfigUtil.parseOptional(properties, SUBJECT_TOKEN_TYPE, TokenTypeURI::parse)) + .actorTokenString(ConfigUtil.parseOptional(properties, ACTOR_TOKEN)) + .actorTokenType(ConfigUtil.parseOptional(properties, ACTOR_TOKEN_TYPE, TokenTypeURI::parse)) + .requestedTokenType( + ConfigUtil.parseOptional(properties, REQUESTED_TOKEN_TYPE, TokenTypeURI::parse)) + .resources(ConfigUtil.parseList(properties, RESOURCES, ",", URI::create)) + .audiences(ConfigUtil.parseList(properties, AUDIENCES, ",", Audience::new)); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/TokenRefreshConfig.java b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/TokenRefreshConfig.java new file mode 100644 index 000000000000..a93ce4c712d8 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/auth/oauth2/config/TokenRefreshConfig.java @@ -0,0 +1,123 @@ +/* + * 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.config; + +import com.nimbusds.oauth2.sdk.GrantType; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.iceberg.rest.auth.oauth2.OAuth2Config; +import org.immutables.value.Value; + +/** Configuration properties for the token refresh feature. */ +@Value.Immutable +public interface TokenRefreshConfig { + + String PREFIX = OAuth2Config.PREFIX + "token-refresh."; + + String ENABLED = PREFIX + "enabled"; + String TOKEN_EXCHANGE_ENABLED = PREFIX + "token-exchange-enabled"; + String ACCESS_TOKEN_LIFESPAN = PREFIX + "access-token-lifespan"; + String SAFETY_MARGIN = PREFIX + "safety-margin"; + + Duration DEFAULT_ACCESS_TOKEN_LIFESPAN = Duration.parse("PT1H"); + Duration DEFAULT_SAFETY_MARGIN = Duration.parse("PT10S"); + + Duration MIN_ACCESS_TOKEN_LIFESPAN = Duration.parse("PT15S"); + Duration MIN_SAFETY_MARGIN = Duration.parse("PT10S"); + + /** + * Whether to enable token refresh. If enabled, the OAuth2 client will automatically refresh its + * access token when it expires. If disabled, the OAuth2 client will only fetch the initial access + * token, but won't refresh it. Defaults to {@code true}. + */ + @Value.Default + default boolean enabled() { + return true; + } + + /** + * Whether to use the token exchange grant to refresh tokens. + * + *

When enabled, the token exchange grant will be used to refresh the access token, if no + * refresh token is available. + * + *

Optional, defaults to {@code true} if the initial grant is {@link + * GrantType#CLIENT_CREDENTIALS}. + */ + Optional tokenExchangeEnabled(); + + /** + * Default access token lifespan; if the OAuth2 server returns an access token without specifying + * its expiration time, this value will be used. + * + *

Optional, defaults to {@link #DEFAULT_ACCESS_TOKEN_LIFESPAN}. Must be a valid ISO-8601 duration. + */ + @Value.Default + default Duration accessTokenLifespan() { + return DEFAULT_ACCESS_TOKEN_LIFESPAN; + } + + /** + * Refresh safety margin to use; a new token will be fetched when the current token's remaining + * lifespan is less than this value. Optional, defaults to {@link #DEFAULT_SAFETY_MARGIN}. Must be + * a valid ISO-8601 duration. + */ + @Value.Default + default Duration safetyMargin() { + return DEFAULT_SAFETY_MARGIN; + } + + @Value.Check + default void validate() { + if (enabled()) { + ConfigValidator validator = new ConfigValidator(); + validator.check( + accessTokenLifespan().compareTo(MIN_ACCESS_TOKEN_LIFESPAN) >= 0, + ACCESS_TOKEN_LIFESPAN, + "access token lifespan must be greater than or equal to %s", + MIN_ACCESS_TOKEN_LIFESPAN); + validator.check( + safetyMargin().compareTo(MIN_SAFETY_MARGIN) >= 0, + SAFETY_MARGIN, + "refresh safety margin must be greater than or equal to %s", + MIN_SAFETY_MARGIN); + validator.check( + safetyMargin().compareTo(accessTokenLifespan()) < 0, + List.of(SAFETY_MARGIN, ACCESS_TOKEN_LIFESPAN), + "refresh safety margin must be less than the access token lifespan"); + validator.validate(); + } + } + + static ImmutableTokenRefreshConfig.Builder parse(Map properties) { + return ImmutableTokenRefreshConfig.builder() + .enabled(ConfigUtil.parseOptional(properties, ENABLED, Boolean::parseBoolean).orElse(true)) + .tokenExchangeEnabled( + ConfigUtil.parseOptional(properties, TOKEN_EXCHANGE_ENABLED, Boolean::parseBoolean)) + .accessTokenLifespan( + ConfigUtil.parseOptional(properties, ACCESS_TOKEN_LIFESPAN, Duration::parse) + .orElse(DEFAULT_ACCESS_TOKEN_LIFESPAN)) + .safetyMargin( + ConfigUtil.parseOptional(properties, SAFETY_MARGIN, Duration::parse) + .orElse(DEFAULT_SAFETY_MARGIN)); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/OAuthErrorResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/OAuthErrorResponseParser.java index 9fa6051e2578..180038f99084 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/OAuthErrorResponseParser.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/OAuthErrorResponseParser.java @@ -22,6 +22,11 @@ import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.util.JsonUtil; +/** + * @deprecated will be removed in 1.14.0, use {@link + * org.apache.iceberg.rest.auth.oauth2.OAuth2Manager} instead. + */ +@Deprecated public class OAuthErrorResponseParser { private OAuthErrorResponseParser() {} diff --git a/core/src/test/java/org/apache/iceberg/rest/auth/TestAuthManagers.java b/core/src/test/java/org/apache/iceberg/rest/auth/TestAuthManagers.java index d49f398d7a47..0005c7b3881f 100644 --- a/core/src/test/java/org/apache/iceberg/rest/auth/TestAuthManagers.java +++ b/core/src/test/java/org/apache/iceberg/rest/auth/TestAuthManagers.java @@ -44,18 +44,64 @@ public void after() { } @Test - void oauth2Explicit() { + void oauth2NewExplicitByShortName() { + try (AuthManager manager = + AuthManagers.loadAuthManager( + "test", + Map.of(AuthProperties.AUTH_TYPE, AuthProperties.AUTH_MANAGER_IMPL_OAUTH2_NEW))) { + assertThat(manager).isInstanceOf(org.apache.iceberg.rest.auth.oauth2.OAuth2Manager.class); + } + assertThat(streamCaptor.toString()) + .contains( + "Loading AuthManager implementation: org.apache.iceberg.rest.auth.oauth2.OAuth2Manager"); + } + + @Test + void oauth2NewExplicitByFQCN() { + try (AuthManager manager = + AuthManagers.loadAuthManager( + "test", + Map.of( + AuthProperties.AUTH_TYPE, + org.apache.iceberg.rest.auth.oauth2.OAuth2Manager.class.getName()))) { + assertThat(manager).isInstanceOf(org.apache.iceberg.rest.auth.oauth2.OAuth2Manager.class); + } + assertThat(streamCaptor.toString()) + .contains( + "Loading AuthManager implementation: org.apache.iceberg.rest.auth.oauth2.OAuth2Manager"); + } + + @Test + void oauth2LegacyExplicitByShortName() { try (AuthManager manager = AuthManagers.loadAuthManager( "test", Map.of(AuthProperties.AUTH_TYPE, AuthProperties.AUTH_TYPE_OAUTH2))) { assertThat(manager).isInstanceOf(OAuth2Manager.class); } assertThat(streamCaptor.toString()) - .contains("Loading AuthManager implementation: org.apache.iceberg.rest.auth.OAuth2Manager"); + .contains( + "The AuthManager implementation org.apache.iceberg.rest.auth.OAuth2Manager " + + "is deprecated and will be removed in a future release. " + + "Please migrate to org.apache.iceberg.rest.auth.oauth2.OAuth2Manager."); + } + + @Test + void oauth2LegacyExplicitByFQCN() { + try (AuthManager manager = + AuthManagers.loadAuthManager( + "test", + Map.of(AuthProperties.AUTH_TYPE, AuthProperties.AUTH_MANAGER_IMPL_OAUTH2_LEGACY))) { + assertThat(manager).isInstanceOf(OAuth2Manager.class); + } + assertThat(streamCaptor.toString()) + .contains( + "The AuthManager implementation org.apache.iceberg.rest.auth.OAuth2Manager " + + "is deprecated and will be removed in a future release. " + + "Please migrate to org.apache.iceberg.rest.auth.oauth2.OAuth2Manager."); } @Test - void oauth2InferredFromToken() { + void oauth2LegacyInferredFromToken() { try (AuthManager manager = AuthManagers.loadAuthManager("test", Map.of(OAuth2Properties.TOKEN, "irrelevant"))) { assertThat(manager).isInstanceOf(OAuth2Manager.class); @@ -65,11 +111,14 @@ void oauth2InferredFromToken() { "Inferring rest.auth.type=oauth2 since property token was provided. " + "Please explicitly set rest.auth.type to avoid this warning."); assertThat(streamCaptor.toString()) - .contains("Loading AuthManager implementation: org.apache.iceberg.rest.auth.OAuth2Manager"); + .contains( + "The AuthManager implementation org.apache.iceberg.rest.auth.OAuth2Manager " + + "is deprecated and will be removed in a future release. " + + "Please migrate to org.apache.iceberg.rest.auth.oauth2.OAuth2Manager."); } @Test - void oauth2InferredFromCredential() { + void oauth2LegacyInferredFromCredential() { try (AuthManager manager = AuthManagers.loadAuthManager("test", Map.of(OAuth2Properties.CREDENTIAL, "irrelevant"))) { assertThat(manager).isInstanceOf(OAuth2Manager.class); @@ -79,7 +128,10 @@ void oauth2InferredFromCredential() { "Inferring rest.auth.type=oauth2 since property credential was provided. " + "Please explicitly set rest.auth.type to avoid this warning."); assertThat(streamCaptor.toString()) - .contains("Loading AuthManager implementation: org.apache.iceberg.rest.auth.OAuth2Manager"); + .contains( + "The AuthManager implementation org.apache.iceberg.rest.auth.OAuth2Manager " + + "is deprecated and will be removed in a future release. " + + "Please migrate to org.apache.iceberg.rest.auth.oauth2.OAuth2Manager."); } @Test diff --git a/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/TestOAuth2Config.java b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/TestOAuth2Config.java new file mode 100644 index 000000000000..a649a5aa9722 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/TestOAuth2Config.java @@ -0,0 +1,87 @@ +/* + * 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.Scope; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.ClientID; +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.auth.oauth2.config.BasicConfig; +import org.apache.iceberg.rest.auth.oauth2.config.TokenExchangeConfig; +import org.apache.iceberg.rest.auth.oauth2.config.TokenRefreshConfig; +import org.junit.jupiter.api.Test; + +class TestOAuth2Config { + + @Test + void testFromProperties() { + Map properties = + ImmutableMap.builder() + .put(BasicConfig.TOKEN_ENDPOINT, "https://example.com/token") + .put(BasicConfig.CLIENT_ID, "Client") + .put(BasicConfig.CLIENT_SECRET, "w00t") + .put(BasicConfig.GRANT_TYPE, GrantType.TOKEN_EXCHANGE.getValue()) + .put(BasicConfig.SCOPE, "test") + .put(BasicConfig.EXTRA_PARAMS + ".key1", "value1") + .put(BasicConfig.EXTRA_PARAMS + ".key2", "value2") + .put(TokenRefreshConfig.SAFETY_MARGIN, "PT20S") + .put(TokenExchangeConfig.SUBJECT_TOKEN, "subject-token") + .build(); + OAuth2Config config = OAuth2Config.of(properties); + assertThat(config).isNotNull(); + assertThat(config.basicConfig()).isNotNull(); + assertThat(config.basicConfig().tokenEndpoint()) + .contains(URI.create("https://example.com/token")); + assertThat(config.basicConfig().grantType()).isEqualTo(GrantType.TOKEN_EXCHANGE); + assertThat(config.basicConfig().clientId()).contains(new ClientID("Client")); + assertThat(config.basicConfig().clientSecret()).contains(new Secret("w00t")); + assertThat(config.basicConfig().scope()).contains(new Scope("test")); + assertThat(config.basicConfig().extraRequestParameters()) + .isEqualTo(Map.of("key1", "value1", "key2", "value2")); + assertThat(config.tokenRefreshConfig()).isNotNull(); + assertThat(config.tokenRefreshConfig().safetyMargin()).isEqualTo(Duration.ofSeconds(20)); + assertThat(config.tokenExchangeConfig()).isNotNull(); + assertThat(config.tokenExchangeConfig().subjectTokenString()).contains("subject-token"); + } + + @Test + void testValidate() { + Map properties = + Map.of( + BasicConfig.GRANT_TYPE, + GrantType.TOKEN_EXCHANGE.getValue(), + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token", + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t"); + assertThatThrownBy(() -> OAuth2Config.of(properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "subject token must be set if grant type is 'urn:ietf:params:oauth:grant-type:token-exchange' (rest.auth.oauth2.token-exchange.subject-token)"); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestBasicConfig.java b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestBasicConfig.java new file mode 100644 index 000000000000..80746de10a5e --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestBasicConfig.java @@ -0,0 +1,257 @@ +/* + * 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.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import com.nimbusds.oauth2.sdk.GrantType; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TestBasicConfig { + + @ParameterizedTest + @MethodSource + @SuppressWarnings("ResultOfMethodCallIgnored") + void testValidate(Map properties, List expected) { + assertThatIllegalArgumentException() + .isThrownBy(() -> BasicConfig.parse(properties).build()) + .withMessage(ConfigValidator.buildDescription(expected.stream())); + } + + @SuppressWarnings("MethodLength") + static Stream testValidate() { + return Stream.of( + Arguments.of( + Map.of(BasicConfig.CLIENT_ID, "Client1", BasicConfig.CLIENT_SECRET, "s3cr3t"), + List.of( + "either issuer URL or token endpoint must be set (rest.auth.oauth2.issuer-url / rest.auth.oauth2.token-endpoint)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.ISSUER_URL, + "realms/master"), + List.of("Issuer URL must not be relative (rest.auth.oauth2.issuer-url)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.ISSUER_URL, + "https://example.com?query"), + List.of("Issuer URL must not have a query part (rest.auth.oauth2.issuer-url)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.ISSUER_URL, + "https://example.com#fragment"), + List.of("Issuer URL must not have a fragment part (rest.auth.oauth2.issuer-url)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.TOKEN_ENDPOINT, + "https://user:pass@example.com"), + List.of( + "Token endpoint must not have a user info part (rest.auth.oauth2.token-endpoint)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.TOKEN_ENDPOINT, + "https://example.com?query"), + List.of("Token endpoint must not have a query part (rest.auth.oauth2.token-endpoint)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.TOKEN_ENDPOINT, + "https://example.com#fragment"), + List.of( + "Token endpoint must not have a fragment part (rest.auth.oauth2.token-endpoint)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.TOKEN_ENDPOINT, + "/token"), + List.of("Token endpoint must not be relative (rest.auth.oauth2.token-endpoint)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.TOKEN_ENDPOINT, + "token"), + List.of("Token endpoint must not be relative (rest.auth.oauth2.token-endpoint)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token"), + List.of("client ID must not be empty (rest.auth.oauth2.client-id)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_AUTH, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue(), + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token"), + List.of( + "client secret must not be empty when client authentication is 'client_secret_basic' (rest.auth.oauth2.client-auth / rest.auth.oauth2.client-secret)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_AUTH, + ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue(), + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token"), + List.of( + "client secret must not be empty when client authentication is 'client_secret_post' (rest.auth.oauth2.client-auth / rest.auth.oauth2.client-secret)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.GRANT_TYPE, + GrantType.TOKEN_EXCHANGE.getValue(), + BasicConfig.CLIENT_AUTH, + ClientAuthenticationMethod.NONE.getValue(), + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token"), + List.of( + "client secret must not be set when client authentication is 'none' (rest.auth.oauth2.client-auth / rest.auth.oauth2.client-secret)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.GRANT_TYPE, + GrantType.CLIENT_CREDENTIALS.getValue(), + BasicConfig.CLIENT_AUTH, + ClientAuthenticationMethod.NONE.getValue(), + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token"), + List.of( + "grant type must not be 'client_credentials' when client authentication is 'none' (rest.auth.oauth2.client-auth / rest.auth.oauth2.grant-type)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.GRANT_TYPE, + GrantType.REFRESH_TOKEN.getValue(), + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token"), + List.of( + "grant type must be one of: 'client_credentials', 'urn:ietf:params:oauth:grant-type:token-exchange' (rest.auth.oauth2.grant-type)")), + Arguments.of( + Map.of( + BasicConfig.CLIENT_AUTH, + "unknown", + BasicConfig.CLIENT_ID, + "Client1", + BasicConfig.CLIENT_SECRET, + "s3cr3t", + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token"), + List.of( + "client authentication method must be one of: 'none', 'client_secret_basic', 'client_secret_post' (rest.auth.oauth2.client-auth)"))); + } + + @ParameterizedTest + @MethodSource + void testParse(Map properties, BasicConfig expected) { + BasicConfig actual = BasicConfig.parse(properties).build(); + assertThat(actual).isEqualTo(expected); + } + + static Stream testParse() { + return Stream.of( + Arguments.of( + Map.of(BasicConfig.ISSUER_URL, "https://example.com", BasicConfig.TOKEN, "my-token"), + ImmutableBasicConfig.builder() + .issuerUrl(URI.create("https://example.com")) + .token(new BearerAccessToken("my-token")) + .build()), + Arguments.of( + Map.of( + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token", + BasicConfig.GRANT_TYPE, + GrantType.TOKEN_EXCHANGE.getValue(), + BasicConfig.CLIENT_AUTH, + "client_secret_post", + BasicConfig.CLIENT_ID, + "my-client", + BasicConfig.CLIENT_SECRET, + "my-secret", + BasicConfig.SCOPE, + "read write", + BasicConfig.SESSION_CACHE_TIMEOUT, + "PT30M", + BasicConfig.EXTRA_PARAMS + ".param1", + "value1", + BasicConfig.EXTRA_PARAMS + ".param2", + "value2"), + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token")) + .grantType(GrantType.TOKEN_EXCHANGE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .clientId(new ClientID("my-client")) + .clientSecret(new Secret("my-secret")) + .scope(new Scope("read", "write")) + .sessionCacheTimeout(Duration.ofMinutes(30)) + .putExtraRequestParameters("param1", "value1") + .putExtraRequestParameters("param2", "value2") + .build())); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestConfigMigrator.java b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestConfigMigrator.java new file mode 100644 index 000000000000..3bb9167a1d2e --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestConfigMigrator.java @@ -0,0 +1,848 @@ +/* + * 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.config; + +import static org.apache.iceberg.rest.auth.oauth2.config.ConfigMigrator.DEFAULT_CLIENT_ID; +import static org.apache.iceberg.rest.auth.oauth2.config.ConfigMigrator.MESSAGE_TEMPLATE_LEGACY_OPTION; +import static org.apache.iceberg.rest.auth.oauth2.config.ConfigMigrator.MESSAGE_TEMPLATE_MERGED_CONTEXTUAL_CONFIG; +import static org.apache.iceberg.rest.auth.oauth2.config.ConfigMigrator.MESSAGE_TEMPLATE_MISSING_TOKEN_ENDPOINT; +import static org.apache.iceberg.rest.auth.oauth2.config.ConfigMigrator.MESSAGE_TEMPLATE_NO_CLIENT_ID; +import static org.apache.iceberg.rest.auth.oauth2.config.ConfigMigrator.MESSAGE_TEMPLATE_RELATIVE_TOKEN_ENDPOINT; +import static org.apache.iceberg.rest.auth.oauth2.config.ConfigMigrator.MESSAGE_TEMPLATE_TABLE_CONFIG_NOT_ALLOWED; +import static org.apache.iceberg.rest.auth.oauth2.config.ConfigMigrator.MESSAGE_TEMPLATE_VENDED_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.list; + +import com.nimbusds.oauth2.sdk.GrantType; +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.Audience; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.token.TokenTypeURI; +import com.nimbusds.oauth2.sdk.token.TypelessAccessToken; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.rest.auth.OAuth2Properties; +import org.apache.iceberg.rest.auth.oauth2.ImmutableOAuth2Config; +import org.apache.iceberg.rest.auth.oauth2.OAuth2Config; +import org.apache.iceberg.util.Pair; +import org.assertj.core.api.ListAssert; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +@SuppressWarnings("deprecation") +class TestConfigMigrator { + + private List>> messages; + private BiConsumer consumer; + + @BeforeEach + void before() { + messages = Lists.newArrayList(); + consumer = (msg, args) -> messages.add(Pair.of(msg, List.of(args))); + } + + @AfterEach + void after() { + messages.clear(); + } + + // Legacy properties migration tests + + @Test + void noLegacyProperties() { + Map input = + Map.of( + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token", + BasicConfig.CLIENT_ID, + "client1", + BasicConfig.CLIENT_SECRET, + "secret", + "non.oauth2.property", + "value"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + // Only OAuth2 properties should be included + assertThat(actual) + .isEqualTo( + Map.of( + BasicConfig.TOKEN_ENDPOINT, + "https://example.com/token", + BasicConfig.CLIENT_ID, + "client1", + BasicConfig.CLIENT_SECRET, + "secret")); + assertThat(messages).isEmpty(); + } + + @Test + void credentialValid() { + Map input = Map.of(OAuth2Properties.CREDENTIAL, "client1:secret1"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual) + .isEqualTo(Map.of(BasicConfig.CLIENT_ID, "client1", BasicConfig.CLIENT_SECRET, "secret1")); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly( + OAuth2Properties.CREDENTIAL, + "s", + BasicConfig.CLIENT_ID + " and " + BasicConfig.CLIENT_SECRET); + } + + @Test + void credentialNoClientId() { + Map input = Map.of(OAuth2Properties.CREDENTIAL, "secret1"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual) + .isEqualTo( + Map.of( + BasicConfig.CLIENT_ID, + DEFAULT_CLIENT_ID.getValue(), + BasicConfig.CLIENT_SECRET, + "secret1")); + assertThat(messages).hasSize(2); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly( + OAuth2Properties.CREDENTIAL, + "s", + BasicConfig.CLIENT_ID + " and " + BasicConfig.CLIENT_SECRET); + assertThatMessage(messages.get(1), MESSAGE_TEMPLATE_NO_CLIENT_ID) + .containsExactly(DEFAULT_CLIENT_ID.getValue()); + } + + @Test + void token() { + Map input = Map.of(OAuth2Properties.TOKEN, "access-token-123"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual).isEqualTo(Map.of(BasicConfig.TOKEN, "access-token-123")); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly(OAuth2Properties.TOKEN, "", BasicConfig.TOKEN); + } + + @Test + void tokenExpiresInMs() { + Map input = Map.of(OAuth2Properties.TOKEN_EXPIRES_IN_MS, "300000"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual) + .isEqualTo( + Map.of(TokenRefreshConfig.ACCESS_TOKEN_LIFESPAN, Duration.ofMillis(300000).toString())); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly( + OAuth2Properties.TOKEN_EXPIRES_IN_MS, "", TokenRefreshConfig.ACCESS_TOKEN_LIFESPAN); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void tokenRefreshEnabled(boolean enabled) { + Map input = + Map.of(OAuth2Properties.TOKEN_REFRESH_ENABLED, String.valueOf(enabled)); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual).isEqualTo(Map.of(TokenRefreshConfig.ENABLED, String.valueOf(enabled))); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly(OAuth2Properties.TOKEN_REFRESH_ENABLED, "", TokenRefreshConfig.ENABLED); + } + + @Test + void oauth2ServerUri() { + Map input = + Map.of(OAuth2Properties.OAUTH2_SERVER_URI, "https://example.com/token"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual).isEqualTo(Map.of(BasicConfig.TOKEN_ENDPOINT, "https://example.com/token")); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly( + OAuth2Properties.OAUTH2_SERVER_URI, + "s", + BasicConfig.ISSUER_URL + " or " + BasicConfig.TOKEN_ENDPOINT); + } + + @Test + void scope() { + Map input = Map.of(OAuth2Properties.SCOPE, "read write admin"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual).isEqualTo(Map.of(BasicConfig.SCOPE, "read write admin")); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly(OAuth2Properties.SCOPE, "", BasicConfig.SCOPE); + } + + @Test + void audience() { + Map input = Map.of(OAuth2Properties.AUDIENCE, "https://api.example.com"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual).isEqualTo(Map.of(TokenExchangeConfig.AUDIENCES, "https://api.example.com")); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly(OAuth2Properties.AUDIENCE, "", TokenExchangeConfig.AUDIENCES); + } + + @Test + void resource() { + Map input = Map.of(OAuth2Properties.RESOURCE, "urn:example:resource"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual).isEqualTo(Map.of(TokenExchangeConfig.RESOURCES, "urn:example:resource")); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly(OAuth2Properties.RESOURCE, "", TokenExchangeConfig.RESOURCES); + } + + @ParameterizedTest + @MethodSource + void vendedTokenExchange(String tokenTypeProperty) { + Map input = Map.of(tokenTypeProperty, "some-value"); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual) + .isEqualTo( + Map.of( + BasicConfig.GRANT_TYPE, + GrantType.TOKEN_EXCHANGE.getValue(), + TokenExchangeConfig.SUBJECT_TOKEN, + "some-value", + TokenExchangeConfig.SUBJECT_TOKEN_TYPE, + tokenTypeProperty, + TokenExchangeConfig.ACTOR_TOKEN, + ConfigUtil.PARENT_TOKEN)); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly( + tokenTypeProperty, + "s", + TokenExchangeConfig.SUBJECT_TOKEN + + ", " + + TokenExchangeConfig.SUBJECT_TOKEN_TYPE + + " and " + + TokenExchangeConfig.ACTOR_TOKEN); + } + + static Stream vendedTokenExchange() { + return Stream.of( + OAuth2Properties.ACCESS_TOKEN_TYPE, + OAuth2Properties.ID_TOKEN_TYPE, + OAuth2Properties.SAML1_TOKEN_TYPE, + OAuth2Properties.SAML2_TOKEN_TYPE, + OAuth2Properties.JWT_TOKEN_TYPE, + OAuth2Properties.REFRESH_TOKEN_TYPE); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void tokenExchangeEnabled(boolean enabled) { + Map input = + Map.of(OAuth2Properties.TOKEN_EXCHANGE_ENABLED, String.valueOf(enabled)); + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual) + .isEqualTo(Map.of(TokenRefreshConfig.TOKEN_EXCHANGE_ENABLED, String.valueOf(enabled))); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly( + OAuth2Properties.TOKEN_EXCHANGE_ENABLED, "", TokenRefreshConfig.TOKEN_EXCHANGE_ENABLED); + } + + @Test + void fullLegacyMigrationScenario() { + Map input = + ImmutableMap.builder() + .put(OAuth2Properties.CREDENTIAL, "client1:secret1") + .put(OAuth2Properties.TOKEN_EXPIRES_IN_MS, "300000") + .put(OAuth2Properties.TOKEN_REFRESH_ENABLED, "true") + .put(OAuth2Properties.OAUTH2_SERVER_URI, "custom/path/to/token") + .put(OAuth2Properties.SCOPE, "read write") + .put(OAuth2Properties.AUDIENCE, "https://api.example.com") + .put(OAuth2Properties.RESOURCE, "urn:example:resource") + .put(OAuth2Properties.TOKEN_EXCHANGE_ENABLED, "false") + .put( + BasicConfig.ISSUER_URL, + "https://idp.example.com") // New property should be preserved + .put("non.oauth2.property", "ignored") // Non-OAuth2 property should be filtered out + .build(); + + Map expected = + ImmutableMap.builder() + .put(BasicConfig.CLIENT_ID, "client1") + .put(BasicConfig.CLIENT_SECRET, "secret1") + .put(BasicConfig.TOKEN_ENDPOINT, "custom/path/to/token") + .put(BasicConfig.ISSUER_URL, "https://idp.example.com") + .put(BasicConfig.SCOPE, "read write") + .put(TokenRefreshConfig.ENABLED, "true") + .put(TokenRefreshConfig.TOKEN_EXCHANGE_ENABLED, "false") + .put(TokenRefreshConfig.ACCESS_TOKEN_LIFESPAN, Duration.ofMillis(300000).toString()) + .put(TokenExchangeConfig.RESOURCES, "urn:example:resource") + .put(TokenExchangeConfig.AUDIENCES, "https://api.example.com") + .build(); + + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual).isEqualTo(expected); + + assertThat(messages).hasSize(8); + List legacyProperties = + messages.stream().map(Pair::second).map(args -> args.get(0)).collect(Collectors.toList()); + + assertThat(legacyProperties) + .containsExactlyInAnyOrder( + OAuth2Properties.CREDENTIAL, + OAuth2Properties.TOKEN_EXPIRES_IN_MS, + OAuth2Properties.TOKEN_REFRESH_ENABLED, + OAuth2Properties.OAUTH2_SERVER_URI, + OAuth2Properties.SCOPE, + OAuth2Properties.AUDIENCE, + OAuth2Properties.RESOURCE, + OAuth2Properties.TOKEN_EXCHANGE_ENABLED); + } + + @ParameterizedTest + @MethodSource + void newPropertyOverridesLegacy( + Map input, Map expectedOutput, String[] expectedWarningArgs) { + Map actual = new ConfigMigrator(consumer).migrateProperties(input); + assertThat(actual).isEqualTo(expectedOutput); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly(expectedWarningArgs); + } + + static Stream newPropertyOverridesLegacy() { + return Stream.of( + Arguments.of( + ImmutableMap.of( + OAuth2Properties.CREDENTIAL, + "legacy-client:legacy-secret", + BasicConfig.CLIENT_ID, + "new-client", + BasicConfig.CLIENT_SECRET, + "new-secret"), + Map.of(BasicConfig.CLIENT_ID, "new-client", BasicConfig.CLIENT_SECRET, "new-secret"), + new String[] { + OAuth2Properties.CREDENTIAL, + "s", + BasicConfig.CLIENT_ID + " and " + BasicConfig.CLIENT_SECRET + }), + Arguments.of( + ImmutableMap.of(OAuth2Properties.TOKEN, "legacy-token", BasicConfig.TOKEN, "new-token"), + Map.of(BasicConfig.TOKEN, "new-token"), + new String[] {OAuth2Properties.TOKEN, "", BasicConfig.TOKEN}), + Arguments.of( + ImmutableMap.of( + OAuth2Properties.OAUTH2_SERVER_URI, + "https://legacy.example.com/token", + BasicConfig.TOKEN_ENDPOINT, + "https://new.example.com/token"), + Map.of(BasicConfig.TOKEN_ENDPOINT, "https://new.example.com/token"), + new String[] { + OAuth2Properties.OAUTH2_SERVER_URI, + "s", + BasicConfig.ISSUER_URL + " or " + BasicConfig.TOKEN_ENDPOINT + }), + Arguments.of( + ImmutableMap.of(OAuth2Properties.SCOPE, "legacy-scope", BasicConfig.SCOPE, "new-scope"), + Map.of(BasicConfig.SCOPE, "new-scope"), + new String[] {OAuth2Properties.SCOPE, "", BasicConfig.SCOPE}), + Arguments.of( + ImmutableMap.of( + OAuth2Properties.AUDIENCE, + "https://legacy.example.com", + TokenExchangeConfig.AUDIENCES, + "https://new.example.com"), + Map.of(TokenExchangeConfig.AUDIENCES, "https://new.example.com"), + new String[] {OAuth2Properties.AUDIENCE, "", TokenExchangeConfig.AUDIENCES}), + Arguments.of( + ImmutableMap.of( + OAuth2Properties.RESOURCE, + "urn:legacy:resource", + TokenExchangeConfig.RESOURCES, + "urn:new:resource"), + Map.of(TokenExchangeConfig.RESOURCES, "urn:new:resource"), + new String[] {OAuth2Properties.RESOURCE, "", TokenExchangeConfig.RESOURCES}), + Arguments.of( + ImmutableMap.of( + OAuth2Properties.TOKEN_REFRESH_ENABLED, + "false", + TokenRefreshConfig.ENABLED, + "true"), + Map.of(TokenRefreshConfig.ENABLED, "true"), + new String[] {OAuth2Properties.TOKEN_REFRESH_ENABLED, "", TokenRefreshConfig.ENABLED}), + Arguments.of( + ImmutableMap.of( + OAuth2Properties.TOKEN_EXCHANGE_ENABLED, + "false", + TokenRefreshConfig.TOKEN_EXCHANGE_ENABLED, + "true"), + Map.of(TokenRefreshConfig.TOKEN_EXCHANGE_ENABLED, "true"), + new String[] { + OAuth2Properties.TOKEN_EXCHANGE_ENABLED, "", TokenRefreshConfig.TOKEN_EXCHANGE_ENABLED + }), + Arguments.of( + ImmutableMap.of( + OAuth2Properties.TOKEN_EXPIRES_IN_MS, + "300000", + TokenRefreshConfig.ACCESS_TOKEN_LIFESPAN, + "PT10M"), + Map.of(TokenRefreshConfig.ACCESS_TOKEN_LIFESPAN, "PT10M"), + new String[] { + OAuth2Properties.TOKEN_EXPIRES_IN_MS, "", TokenRefreshConfig.ACCESS_TOKEN_LIFESPAN + })); + } + + // Token endpoint URL handling tests + + @ParameterizedTest + @CsvSource({ + "https://example.com , /oauth2/token , https://example.com/oauth2/token", + "https://example.com , oauth2/token , https://example.com/oauth2/token", + "https://example.com/ , /oauth2/token , https://example.com/oauth2/token", + "https://example.com/ , oauth2/token , https://example.com/oauth2/token", + "https://example.com/api , /oauth2/token , https://example.com/api/oauth2/token", + "https://example.com/api/ , oauth2/token , https://example.com/api/oauth2/token" + }) + void legacyTokenEndpoint(String catalogUri, String oauth2ServerUri, String expected) { + Map input = Map.of(OAuth2Properties.OAUTH2_SERVER_URI, oauth2ServerUri); + ConfigMigrator migrator = new ConfigMigrator(consumer); + Map actual = migrator.migrateProperties(input); + migrator.handleTokenEndpoint(actual, catalogUri); + assertThat(actual).isEqualTo(Map.of(BasicConfig.TOKEN_ENDPOINT, expected)); + assertThat(messages).hasSize(2); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_LEGACY_OPTION) + .containsExactly( + OAuth2Properties.OAUTH2_SERVER_URI, + "s", + BasicConfig.ISSUER_URL + " or " + BasicConfig.TOKEN_ENDPOINT); + assertThatMessage(messages.get(1), MESSAGE_TEMPLATE_RELATIVE_TOKEN_ENDPOINT) + .containsExactly(expected); + } + + @ParameterizedTest + @CsvSource({ + "https://example.com , https://example.com/v1/oauth/tokens", + "https://example.com/ , https://example.com/v1/oauth/tokens", + "https://example.com/api , https://example.com/api/v1/oauth/tokens", + "https://example.com/api/ , https://example.com/api/v1/oauth/tokens" + }) + void tokenEndpointMissing(String catalogUri, String expected) { + Map input = + Map.of(BasicConfig.CLIENT_ID, "client-id", BasicConfig.CLIENT_SECRET, "client-secret"); + ConfigMigrator migrator = new ConfigMigrator(consumer); + Map actual = migrator.migrateProperties(input); + migrator.handleTokenEndpoint(actual, catalogUri); + assertThat(actual) + .isEqualTo( + Map.of( + BasicConfig.CLIENT_ID, + "client-id", + BasicConfig.CLIENT_SECRET, + "client-secret", + BasicConfig.TOKEN_ENDPOINT, + expected)); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_MISSING_TOKEN_ENDPOINT) + .containsExactly(expected, BasicConfig.TOKEN_ENDPOINT, BasicConfig.ISSUER_URL); + } + + @Test + void tokenEndpointMissingWithIssuerUrl() { + Map input = + Map.of( + BasicConfig.ISSUER_URL, + "https://issuer.com", + BasicConfig.CLIENT_ID, + "client-id", + BasicConfig.CLIENT_SECRET, + "client-secret"); + ConfigMigrator migrator = new ConfigMigrator(consumer); + Map actual = migrator.migrateProperties(input); + migrator.handleTokenEndpoint(actual, "https://catalog.com"); + assertThat(actual).isEqualTo(input); + assertThat(messages).hasSize(0); + } + + @Test + void tokenEndpointMissingWithStaticToken() { + Map input = Map.of(BasicConfig.TOKEN, "static-token"); + ConfigMigrator migrator = new ConfigMigrator(consumer); + Map actual = migrator.migrateProperties(input); + migrator.handleTokenEndpoint(actual, "https://catalog.com"); + assertThat(actual).isEqualTo(input); + assertThat(messages).hasSize(0); + } + + @Test + void tokenEndpointRelative() { + Map input = + Map.of( + BasicConfig.TOKEN_ENDPOINT, + "/relative/token/endpoint", + BasicConfig.CLIENT_ID, + "client-id", + BasicConfig.CLIENT_SECRET, + "client-secret"); + ConfigMigrator migrator = new ConfigMigrator(consumer); + Map actual = migrator.migrateProperties(input); + migrator.handleTokenEndpoint(actual, "https://catalog.com"); + assertThat(actual) + .isEqualTo( + Map.of( + BasicConfig.TOKEN_ENDPOINT, + "https://catalog.com/relative/token/endpoint", + BasicConfig.CLIENT_ID, + "client-id", + BasicConfig.CLIENT_SECRET, + "client-secret")); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_RELATIVE_TOKEN_ENDPOINT) + .containsExactly("https://catalog.com/relative/token/endpoint"); + } + + @Test + void tokenEndpointAbsolute() { + Map input = + Map.of( + BasicConfig.TOKEN_ENDPOINT, + "https://token-endpoint.com/token", + BasicConfig.CLIENT_ID, + "client-id", + BasicConfig.CLIENT_SECRET, + "client-secret"); + ConfigMigrator migrator = new ConfigMigrator(consumer); + Map actual = migrator.migrateProperties(input); + migrator.handleTokenEndpoint(actual, "https://catalog.com"); + assertThat(actual).isEqualTo(input); + assertThat(messages).hasSize(0); + } + + // Full config migration tests + + @Test + void migrateCatalogConfig() { + Map input = + ImmutableMap.builder() + .put(BasicConfig.TOKEN_ENDPOINT, "https://example.com/token") + .put(BasicConfig.CLIENT_ID, "client-id") + .put(BasicConfig.CLIENT_SECRET, "client-secret") + .build(); + ConfigMigrator migrator = new ConfigMigrator(consumer); + OAuth2Config actual = migrator.migrateCatalogConfig(input, "https://example.com"); + assertThat(actual) + .isEqualTo( + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token")) + .clientId(new ClientID("client-id")) + .clientSecret(new Secret("client-secret")) + .build()) + .build()); + assertThat(messages).hasSize(0); + } + + @Test + void migrateContextualConfigFromEmptyInputEmptyParent() { + // minimal parent config with just a token + OAuth2Config parent = + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .token(new TypelessAccessToken("access-token-123")) + .build()) + .build(); + Map input = Map.of(); + ConfigMigrator migrator = new ConfigMigrator(consumer); + OAuth2Config actual = migrator.migrateContextualConfig(parent, input, "https://example.com"); + assertThat(actual).isSameAs(parent); + assertThat(messages).hasSize(0); + } + + @Test + void migrateContextualConfigFromEmptyInputNonEmptyParent() { + OAuth2Config parent = + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token")) + .clientId(new ClientID("parent-client-id")) + .clientSecret(new Secret("parent-client-secret")) + .scope(Scope.parse("parent-scope")) + .build()) + .tokenExchangeConfig( + ImmutableTokenExchangeConfig.builder() + .addAudiences(new Audience("parent-audience")) + .addResources(URI.create("parent-resource")) + .build()) + .build(); + Map input = Map.of(); + ConfigMigrator migrator = new ConfigMigrator(consumer); + OAuth2Config actual = migrator.migrateContextualConfig(parent, input, "https://example.com"); + assertThat(actual).isEqualTo(parent); + assertThat(messages).hasSize(6); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_MERGED_CONTEXTUAL_CONFIG) + .containsExactly(BasicConfig.CLIENT_ID); + assertThatMessage(messages.get(1), MESSAGE_TEMPLATE_MERGED_CONTEXTUAL_CONFIG) + .containsExactly(BasicConfig.CLIENT_SECRET); + assertThatMessage(messages.get(2), MESSAGE_TEMPLATE_MERGED_CONTEXTUAL_CONFIG) + .containsExactly(BasicConfig.TOKEN_ENDPOINT); + assertThatMessage(messages.get(3), MESSAGE_TEMPLATE_MERGED_CONTEXTUAL_CONFIG) + .containsExactly(BasicConfig.SCOPE); + assertThatMessage(messages.get(4), MESSAGE_TEMPLATE_MERGED_CONTEXTUAL_CONFIG) + .containsExactly(TokenExchangeConfig.RESOURCES); + assertThatMessage(messages.get(5), MESSAGE_TEMPLATE_MERGED_CONTEXTUAL_CONFIG) + .containsExactly(TokenExchangeConfig.AUDIENCES); + } + + @Test + void migrateContextualConfigFromNonEmptyInputEmptyParent() { + // minimal parent config with just a token + OAuth2Config parent = + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .token(new TypelessAccessToken("access-token-123")) + .build()) + .build(); + Map input = + Map.of( + BasicConfig.CLIENT_ID, "child-client-id", + BasicConfig.CLIENT_SECRET, "child-client-secret", + BasicConfig.TOKEN_ENDPOINT, "https://example.com/token/child", + BasicConfig.SCOPE, "child-scope", + TokenExchangeConfig.RESOURCES, "child-resource", + TokenExchangeConfig.AUDIENCES, "child-audience"); + ConfigMigrator migrator = new ConfigMigrator(consumer); + OAuth2Config actual = migrator.migrateContextualConfig(parent, input, "https://example.com"); + assertThat(actual) + .isEqualTo( + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token/child")) + .clientId(new ClientID("child-client-id")) + .clientSecret(new Secret("child-client-secret")) + .scope(Scope.parse("child-scope")) + .build()) + .tokenExchangeConfig( + ImmutableTokenExchangeConfig.builder() + .addAudiences(new Audience("child-audience")) + .addResources(URI.create("child-resource")) + .build()) + .build()); + assertThat(messages).hasSize(0); + } + + @Test + void migrateContextualConfigFromNonEmptyInputNonEmptyParent() { + OAuth2Config parent = + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token/parent")) + .clientId(new ClientID("parent-client-id")) + .clientSecret(new Secret("parent-client-secret")) + .scope(Scope.parse("parent-scope")) + .build()) + .tokenExchangeConfig( + ImmutableTokenExchangeConfig.builder() + .addAudiences(new Audience("parent-audience")) + .addResources(URI.create("parent-resource")) + .build()) + .build(); + Map input = + Map.of( + BasicConfig.CLIENT_ID, "child-client-id", + BasicConfig.CLIENT_SECRET, "child-client-secret", + BasicConfig.TOKEN_ENDPOINT, "https://example.com/token/child", + BasicConfig.SCOPE, "child-scope", + TokenExchangeConfig.RESOURCES, "child-resource", + TokenExchangeConfig.AUDIENCES, "child-audience"); + ConfigMigrator migrator = new ConfigMigrator(consumer); + OAuth2Config actual = migrator.migrateContextualConfig(parent, input, "https://example.com"); + assertThat(actual) + .isEqualTo( + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token/child")) + .clientId(new ClientID("child-client-id")) + .clientSecret(new Secret("child-client-secret")) + .scope(Scope.parse("child-scope")) + .build()) + .tokenExchangeConfig( + ImmutableTokenExchangeConfig.builder() + .addAudiences(new Audience("child-audience")) + .addResources(URI.create("child-resource")) + .build()) + .build()); + assertThat(messages).hasSize(0); + } + + @Test + void migrateTableConfigFromEmptyInput() { + OAuth2Config parent = + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token/parent")) + .clientId(new ClientID("parent-client-id")) + .clientSecret(new Secret("parent-client-secret")) + .scope(Scope.parse("parent-scope")) + .build()) + .build(); + Map input = Map.of(); + ConfigMigrator migrator = new ConfigMigrator(consumer); + OAuth2Config actual = migrator.migrateTableConfig(parent, input); + assertThat(actual).isEqualTo(parent); + assertThat(messages).hasSize(0); + } + + @Test + void migrateTableConfigFromDisallowedInput() { + OAuth2Config parent = + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token/parent")) + .clientId(new ClientID("parent-client-id")) + .clientSecret(new Secret("parent-client-secret")) + .scope(Scope.parse("parent-scope")) + .build()) + .build(); + // should be filtered out + Map input = + ImmutableMap.of( + BasicConfig.CLIENT_ID, "child-client-id", + BasicConfig.CLIENT_SECRET, "child-client-secret", + BasicConfig.SCOPE, "table-scope"); + ConfigMigrator migrator = new ConfigMigrator(consumer); + OAuth2Config actual = migrator.migrateTableConfig(parent, input); + assertThat(actual).isEqualTo(parent); + assertThat(messages).hasSize(3); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_TABLE_CONFIG_NOT_ALLOWED) + .containsExactly(BasicConfig.CLIENT_ID); + assertThatMessage(messages.get(1), MESSAGE_TEMPLATE_TABLE_CONFIG_NOT_ALLOWED) + .containsExactly(BasicConfig.CLIENT_SECRET); + assertThatMessage(messages.get(2), MESSAGE_TEMPLATE_TABLE_CONFIG_NOT_ALLOWED) + .containsExactly(BasicConfig.SCOPE); + } + + @Test + void migrateTableConfigFromNonEmptyInputWithVendedStaticToken() { + OAuth2Config parent = + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token/parent")) + .clientId(new ClientID("parent-client-id")) + .clientSecret(new Secret("parent-client-secret")) + .scope(Scope.parse("parent-scope")) + .build()) + .build(); + Map input = + ImmutableMap.builder().put(BasicConfig.TOKEN, "access-token-123").build(); + ConfigMigrator migrator = new ConfigMigrator(consumer); + OAuth2Config actual = migrator.migrateTableConfig(parent, input); + assertThat(actual) + .isEqualTo( + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + // from table config + .token(new TypelessAccessToken("access-token-123")) + // from parent config + .tokenEndpoint(URI.create("https://example.com/token/parent")) + .clientId(new ClientID("parent-client-id")) + .clientSecret(new Secret("parent-client-secret")) + .scope(Scope.parse("parent-scope")) + .build()) + .build()); + assertThat(messages).hasSize(1); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_VENDED_TOKEN) + .containsExactly(BasicConfig.TOKEN); + } + + @Test + void migrateTableConfigFromNonEmptyInputWithVendedTokenExchange() throws ParseException { + OAuth2Config parent = + ImmutableOAuth2Config.builder() + .basicConfig( + ImmutableBasicConfig.builder() + .tokenEndpoint(URI.create("https://example.com/token/parent")) + .clientId(new ClientID("parent-client-id")) + .clientSecret(new Secret("parent-client-secret")) + .scope(Scope.parse("parent-scope")) + .build()) + .build(); + Map input = + ImmutableMap.builder() + .put(TokenExchangeConfig.SUBJECT_TOKEN, "id-token-123") + .put(TokenExchangeConfig.SUBJECT_TOKEN_TYPE, OAuth2Properties.ID_TOKEN_TYPE) + .put(TokenExchangeConfig.ACTOR_TOKEN, ConfigUtil.PARENT_TOKEN) + .put(TokenExchangeConfig.ACTOR_TOKEN_TYPE, OAuth2Properties.ACCESS_TOKEN_TYPE) + .build(); + ConfigMigrator migrator = new ConfigMigrator(consumer); + OAuth2Config actual = migrator.migrateTableConfig(parent, input); + assertThat(actual) + .isEqualTo( + ImmutableOAuth2Config.builder() + // from parent config + .basicConfig( + ImmutableBasicConfig.builder() + .grantType(GrantType.TOKEN_EXCHANGE) + .tokenEndpoint(URI.create("https://example.com/token/parent")) + .clientId(new ClientID("parent-client-id")) + .clientSecret(new Secret("parent-client-secret")) + .scope(Scope.parse("parent-scope")) + .build()) + // from table config + .tokenExchangeConfig( + ImmutableTokenExchangeConfig.builder() + .subjectTokenString("id-token-123") + .subjectTokenType(TokenTypeURI.parse(OAuth2Properties.ID_TOKEN_TYPE)) + .actorTokenString(ConfigUtil.PARENT_TOKEN) + .actorTokenType(TokenTypeURI.parse(OAuth2Properties.ACCESS_TOKEN_TYPE)) + .build()) + .build()); + assertThat(messages).hasSize(4); + assertThatMessage(messages.get(0), MESSAGE_TEMPLATE_VENDED_TOKEN) + .containsExactly(TokenExchangeConfig.SUBJECT_TOKEN); + assertThatMessage(messages.get(1), MESSAGE_TEMPLATE_VENDED_TOKEN) + .containsExactly(TokenExchangeConfig.SUBJECT_TOKEN_TYPE); + assertThatMessage(messages.get(2), MESSAGE_TEMPLATE_VENDED_TOKEN) + .containsExactly(TokenExchangeConfig.ACTOR_TOKEN); + assertThatMessage(messages.get(3), MESSAGE_TEMPLATE_VENDED_TOKEN) + .containsExactly(TokenExchangeConfig.ACTOR_TOKEN_TYPE); + } + + private static ListAssert assertThatMessage( + Pair> message, String template) { + assertThat(message).extracting(Pair::first).isEqualTo(template); + return assertThat(message).extracting(Pair::second).asInstanceOf(list(String.class)); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestConfigUtil.java b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestConfigUtil.java new file mode 100644 index 000000000000..66b53a57b91b --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestConfigUtil.java @@ -0,0 +1,114 @@ +/* + * 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.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TestConfigUtil { + + @ParameterizedTest + @MethodSource + void requiresClientSecret(ClientAuthenticationMethod method, boolean expectedResult) { + assertThat(ConfigUtil.requiresClientSecret(method)).isEqualTo(expectedResult); + } + + static Stream requiresClientSecret() { + return Stream.of( + Arguments.of(null, false), + Arguments.of(ClientAuthenticationMethod.CLIENT_SECRET_BASIC, true), + Arguments.of(ClientAuthenticationMethod.CLIENT_SECRET_POST, true), + Arguments.of(ClientAuthenticationMethod.NONE, false)); + } + + @Test + void parseOptional() { + assertThat(ConfigUtil.parseOptional(Map.of("a", "1"), "a")).hasValue("1"); + assertThat(ConfigUtil.parseOptional(Map.of("a", "1"), "b")).isEmpty(); + } + + @Test + void parseOptionalWithParser() { + assertThat(ConfigUtil.parseOptional(Map.of("a", "1"), "a", Integer::parseInt)).hasValue(1); + assertThat(ConfigUtil.parseOptional(Map.of("a", "1"), "b", Integer::parseInt)).isEmpty(); + } + + @Test + void parseOptionalWithParserFailure() { + assertThatThrownBy( + () -> ConfigUtil.parseOptional(Map.of("a", "invalid"), "a", Integer::parseInt)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse configuration value 'invalid'") + .hasRootCauseInstanceOf(NumberFormatException.class); + } + + @Test + void parseOptionalInt() { + assertThat(ConfigUtil.parseOptionalInt(Map.of("a", "1"), "a")).hasValue(1); + assertThat(ConfigUtil.parseOptionalInt(Map.of("a", "1"), "b")).isEmpty(); + } + + @Test + void parseOptionalIntFailure() { + assertThatThrownBy(() -> ConfigUtil.parseOptionalInt(Map.of("a", "invalid"), "a")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse configuration value 'invalid'") + .hasRootCauseInstanceOf(NumberFormatException.class); + } + + @Test + void parseList() { + assertThat(ConfigUtil.parseList(Map.of("a", ""), "a", ",")).isEmpty(); + assertThat(ConfigUtil.parseList(Map.of("a", "1,2,3"), "a", ",")).containsExactly("1", "2", "3"); + assertThat(ConfigUtil.parseList(Map.of("a", "1 , 2 , 3"), "a", ",")) + .containsExactly("1", "2", "3"); + assertThat(ConfigUtil.parseList(Map.of("a", "1 2 3"), "a", " ")) + .containsExactly("1", "2", "3"); + assertThat(ConfigUtil.parseList(Map.of("a", "1,2,3"), "b", ",")).isEmpty(); + } + + @Test + void parseListWithParser() { + assertThat(ConfigUtil.parseList(Map.of("a", ""), "a", ",", Integer::parseInt)).isEmpty(); + assertThat(ConfigUtil.parseList(Map.of("a", "1,2,3"), "a", ",", Integer::parseInt)) + .containsExactly(1, 2, 3); + assertThat(ConfigUtil.parseList(Map.of("a", "1 , 2 , 3"), "a", ",", Integer::parseInt)) + .containsExactly(1, 2, 3); + assertThat(ConfigUtil.parseList(Map.of("a", "1 2 3"), "a", " ", Integer::parseInt)) + .containsExactly(1, 2, 3); + assertThat(ConfigUtil.parseList(Map.of("a", "1,2,3"), "b", ",", Integer::parseInt)).isEmpty(); + } + + @Test + void parseListWithParserFailure() { + assertThatThrownBy( + () -> ConfigUtil.parseList(Map.of("a", "1,invalid,3"), "a", ",", Integer::parseInt)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse configuration value 'invalid'") + .hasRootCauseInstanceOf(NumberFormatException.class); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestTokenExchangeConfig.java b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestTokenExchangeConfig.java new file mode 100644 index 000000000000..b971681b42c5 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestTokenExchangeConfig.java @@ -0,0 +1,95 @@ +/* + * 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.config; + +import static org.apache.iceberg.rest.auth.oauth2.config.TokenExchangeConfig.ACTOR_TOKEN; +import static org.apache.iceberg.rest.auth.oauth2.config.TokenExchangeConfig.ACTOR_TOKEN_TYPE; +import static org.apache.iceberg.rest.auth.oauth2.config.TokenExchangeConfig.AUDIENCES; +import static org.apache.iceberg.rest.auth.oauth2.config.TokenExchangeConfig.REQUESTED_TOKEN_TYPE; +import static org.apache.iceberg.rest.auth.oauth2.config.TokenExchangeConfig.RESOURCES; +import static org.apache.iceberg.rest.auth.oauth2.config.TokenExchangeConfig.SUBJECT_TOKEN; +import static org.apache.iceberg.rest.auth.oauth2.config.TokenExchangeConfig.SUBJECT_TOKEN_TYPE; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.id.Audience; +import com.nimbusds.oauth2.sdk.token.TokenTypeURI; +import java.net.URI; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TestTokenExchangeConfig { + + @ParameterizedTest + @MethodSource + void testParse(Map properties, TokenExchangeConfig expected) { + TokenExchangeConfig actual = TokenExchangeConfig.parse(properties).build(); + assertThat(actual).isEqualTo(expected); + } + + static Stream testParse() throws ParseException { + return Stream.of( + Arguments.of(Map.of(), ImmutableTokenExchangeConfig.builder().build()), + Arguments.of( + Map.of( + SUBJECT_TOKEN, + "my-subject-token", + SUBJECT_TOKEN_TYPE, + "urn:ietf:params:oauth:token-type:jwt"), + ImmutableTokenExchangeConfig.builder() + .subjectTokenString("my-subject-token") + .subjectTokenType(TokenTypeURI.parse("urn:ietf:params:oauth:token-type:jwt")) + .build()), + Arguments.of( + Map.of( + ACTOR_TOKEN, + "my-actor-token", + ACTOR_TOKEN_TYPE, + "urn:ietf:params:oauth:token-type:jwt"), + ImmutableTokenExchangeConfig.builder() + .actorTokenString("my-actor-token") + .actorTokenType(TokenTypeURI.parse("urn:ietf:params:oauth:token-type:jwt")) + .build()), + Arguments.of( + Map.of(REQUESTED_TOKEN_TYPE, "urn:ietf:params:oauth:token-type:jwt"), + ImmutableTokenExchangeConfig.builder() + .requestedTokenType(TokenTypeURI.parse("urn:ietf:params:oauth:token-type:jwt")) + .build()), + Arguments.of( + Map.of(RESOURCES, "https://example.com/api"), + ImmutableTokenExchangeConfig.builder() + .addResources(URI.create("https://example.com/api")) + .build()), + Arguments.of( + Map.of(AUDIENCES, "https://example.com/resource"), + ImmutableTokenExchangeConfig.builder() + .addAudiences(new Audience("https://example.com/resource")) + .build()), + Arguments.of( + Map.of(AUDIENCES, "https://example.com/resource1,https://example.com/resource2"), + ImmutableTokenExchangeConfig.builder() + .addAudiences( + new Audience("https://example.com/resource1"), + new Audience("https://example.com/resource2")) + .build())); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestTokenRefreshConfig.java b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestTokenRefreshConfig.java new file mode 100644 index 000000000000..6c1847121847 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/auth/oauth2/config/TestTokenRefreshConfig.java @@ -0,0 +1,88 @@ +/* + * 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.config; + +import static org.apache.iceberg.rest.auth.oauth2.config.TokenRefreshConfig.ACCESS_TOKEN_LIFESPAN; +import static org.apache.iceberg.rest.auth.oauth2.config.TokenRefreshConfig.ENABLED; +import static org.apache.iceberg.rest.auth.oauth2.config.TokenRefreshConfig.SAFETY_MARGIN; +import static org.apache.iceberg.rest.auth.oauth2.config.TokenRefreshConfig.TOKEN_EXCHANGE_ENABLED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TestTokenRefreshConfig { + + @ParameterizedTest + @MethodSource + @SuppressWarnings("ResultOfMethodCallIgnored") + void testValidate(Map properties, List expected) { + assertThatIllegalArgumentException() + .isThrownBy(() -> TokenRefreshConfig.parse(properties).build()) + .withMessage(ConfigValidator.buildDescription(expected.stream())); + } + + static Stream testValidate() { + return Stream.of( + Arguments.of( + Map.of(ACCESS_TOKEN_LIFESPAN, "PT2S"), + List.of( + "access token lifespan must be greater than or equal to PT15S (rest.auth.oauth2.token-refresh.access-token-lifespan)", + "refresh safety margin must be less than the access token lifespan (rest.auth.oauth2.token-refresh.safety-margin / rest.auth.oauth2.token-refresh.access-token-lifespan)")), + Arguments.of( + Map.of(SAFETY_MARGIN, "PT0.1S"), + List.of( + "refresh safety margin must be greater than or equal to PT10S (rest.auth.oauth2.token-refresh.safety-margin)")), + Arguments.of( + Map.of(SAFETY_MARGIN, "PT10M", ACCESS_TOKEN_LIFESPAN, "PT5M"), + List.of( + "refresh safety margin must be less than the access token lifespan (rest.auth.oauth2.token-refresh.safety-margin / rest.auth.oauth2.token-refresh.access-token-lifespan)"))); + } + + @ParameterizedTest + @MethodSource + void testParse(Map properties, TokenRefreshConfig expected) { + TokenRefreshConfig actual = TokenRefreshConfig.parse(properties).build(); + assertThat(actual).isEqualTo(expected); + } + + static Stream testParse() { + return Stream.of( + Arguments.of(Map.of(), ImmutableTokenRefreshConfig.builder().build()), + Arguments.of( + Map.of(ENABLED, "false"), ImmutableTokenRefreshConfig.builder().enabled(false).build()), + Arguments.of( + Map.of(TOKEN_EXCHANGE_ENABLED, "false"), + ImmutableTokenRefreshConfig.builder().tokenExchangeEnabled(false).build()), + Arguments.of( + Map.of(ACCESS_TOKEN_LIFESPAN, "PT10M"), + ImmutableTokenRefreshConfig.builder() + .accessTokenLifespan(Duration.ofMinutes(10)) + .build()), + Arguments.of( + Map.of(SAFETY_MARGIN, "PT30S"), + ImmutableTokenRefreshConfig.builder().safetyMargin(Duration.ofSeconds(30)).build())); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index abe2c3f543eb..b9c0dd11a7a5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,6 +78,8 @@ mockito = "4.11.0" mockserver = "5.15.0" nessie = "0.107.4" netty-buffer = "4.2.10.Final" +nimbus-oauth2 = "11.34" +nimbus-jose-jwt = "10.8" object-client-bundle = "3.3.2" orc = "1.9.8" parquet = "1.17.0" @@ -166,6 +168,8 @@ lz4Java = { module = "at.yawk.lz4:lz4-java", version.ref = "lz4Java" } microprofile-openapi-api = { module = "org.eclipse.microprofile.openapi:microprofile-openapi-api", version.ref = "microprofile-openapi-api" } nessie-client = { module = "org.projectnessie.nessie:nessie-client", version.ref = "nessie" } netty-buffer = { module = "io.netty:netty-buffer", version.ref = "netty-buffer" } +nimbus-oauth2-oidc-sdk = { module = "com.nimbusds:oauth2-oidc-sdk", version.ref = "nimbus-oauth2" } +nimbus-jose-jwt = { module = "com.nimbusds:nimbus-jose-jwt", version.ref = "nimbus-jose-jwt" } object-client-bundle = { module = "com.emc.ecs:object-client-bundle", version.ref = "object-client-bundle" } orc-core = { module = "org.apache.orc:orc-core", version.ref = "orc" } parquet-avro = { module = "org.apache.parquet:parquet-avro", version.ref = "parquet" }