diff --git a/api/src/main/java/org/apache/iceberg/exceptions/OAuth2BadRequestException.java b/api/src/main/java/org/apache/iceberg/exceptions/OAuth2BadRequestException.java new file mode 100644 index 000000000000..9318c6ebbce3 --- /dev/null +++ b/api/src/main/java/org/apache/iceberg/exceptions/OAuth2BadRequestException.java @@ -0,0 +1,49 @@ +/* + * 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.exceptions; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; + +/** + * Bad-request exception raised when an OAuth2 token-endpoint response carries one of the {@code + * 400}-class error codes from RFC 6749 §5.2 (e.g. {@code invalid_request}, {@code invalid_grant}, + * {@code unauthorized_client}, {@code unsupported_grant_type}, {@code invalid_scope}). + */ +public class OAuth2BadRequestException extends BadRequestException implements OAuth2Error { + private final String errorType; + + @FormatMethod + public OAuth2BadRequestException(String errorType, @FormatString String message, Object... args) { + super(message, args); + this.errorType = errorType; + } + + @FormatMethod + public OAuth2BadRequestException( + String errorType, Throwable cause, @FormatString String message, Object... args) { + super(cause, message, args); + this.errorType = errorType; + } + + @Override + public String errorType() { + return errorType; + } +} diff --git a/api/src/main/java/org/apache/iceberg/exceptions/OAuth2Error.java b/api/src/main/java/org/apache/iceberg/exceptions/OAuth2Error.java new file mode 100644 index 000000000000..a3e145912c94 --- /dev/null +++ b/api/src/main/java/org/apache/iceberg/exceptions/OAuth2Error.java @@ -0,0 +1,29 @@ +/* + * 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.exceptions; + +/** + * Marker interface for exceptions arising from an OAuth2 token-endpoint error response (RFC 6749 + * §5.2). The {@link #errorType()} value is one of the RFC 6749 §5.2 error codes (e.g. {@code + * "invalid_grant"}, {@code "invalid_client"}). + */ +public interface OAuth2Error { + /** The OAuth2 error code from RFC 6749 §5.2 (e.g. {@code "invalid_grant"}). */ + String errorType(); +} diff --git a/api/src/main/java/org/apache/iceberg/exceptions/OAuth2NotAuthorizedException.java b/api/src/main/java/org/apache/iceberg/exceptions/OAuth2NotAuthorizedException.java new file mode 100644 index 000000000000..e705ebc3a5e6 --- /dev/null +++ b/api/src/main/java/org/apache/iceberg/exceptions/OAuth2NotAuthorizedException.java @@ -0,0 +1,49 @@ +/* + * 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.exceptions; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; + +/** + * Not-authorized exception raised when an OAuth2 token-endpoint response carries the {@code + * invalid_client} error code from RFC 6749 §5.2. + */ +public class OAuth2NotAuthorizedException extends NotAuthorizedException implements OAuth2Error { + private final String errorType; + + @FormatMethod + public OAuth2NotAuthorizedException( + String errorType, @FormatString String message, Object... args) { + super(message, args); + this.errorType = errorType; + } + + @FormatMethod + public OAuth2NotAuthorizedException( + String errorType, Throwable cause, @FormatString String message, Object... args) { + super(cause, message, args); + this.errorType = errorType; + } + + @Override + public String errorType() { + return errorType; + } +} 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 334bfde8abfc..62bdec2d23c4 100644 --- a/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java +++ b/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java @@ -33,6 +33,8 @@ import org.apache.iceberg.exceptions.NoSuchWarehouseException; import org.apache.iceberg.exceptions.NotAuthorizedException; import org.apache.iceberg.exceptions.NotFoundException; +import org.apache.iceberg.exceptions.OAuth2BadRequestException; +import org.apache.iceberg.exceptions.OAuth2NotAuthorizedException; import org.apache.iceberg.exceptions.RESTException; import org.apache.iceberg.exceptions.ServiceFailureException; import org.apache.iceberg.exceptions.ServiceUnavailableException; @@ -376,15 +378,15 @@ public void accept(ErrorResponse error) { if (error.type() != null) { switch (error.type()) { case OAuth2Properties.INVALID_CLIENT_ERROR: - throw new NotAuthorizedException( - "Not authorized: %s: %s", error.type(), error.message()); + throw new OAuth2NotAuthorizedException( + error.type(), "Not authorized: %s: %s", error.type(), error.message()); case OAuth2Properties.INVALID_REQUEST_ERROR: case OAuth2Properties.INVALID_GRANT_ERROR: case OAuth2Properties.UNAUTHORIZED_CLIENT_ERROR: case OAuth2Properties.UNSUPPORTED_GRANT_TYPE_ERROR: case OAuth2Properties.INVALID_SCOPE_ERROR: - throw new BadRequestException( - "Malformed request: %s: %s", error.type(), error.message()); + throw new OAuth2BadRequestException( + error.type(), "Malformed request: %s: %s", error.type(), error.message()); } } throw createRESTException(error); diff --git a/core/src/test/java/org/apache/iceberg/rest/TestErrorHandlers.java b/core/src/test/java/org/apache/iceberg/rest/TestErrorHandlers.java index b7bbe337cd27..4252db38d2ba 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestErrorHandlers.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestErrorHandlers.java @@ -21,9 +21,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.apache.iceberg.exceptions.NoSuchWarehouseException; +import org.apache.iceberg.exceptions.OAuth2BadRequestException; +import org.apache.iceberg.exceptions.OAuth2NotAuthorizedException; import org.apache.iceberg.exceptions.RESTException; import org.apache.iceberg.exceptions.ServiceFailureException; +import org.apache.iceberg.rest.auth.OAuth2Properties; import org.apache.iceberg.rest.responses.ErrorResponse; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; public class TestErrorHandlers { @@ -104,4 +108,106 @@ public void testConfigErrorHandlerDelegatesToDefaultForNon404() { .isInstanceOf(ServiceFailureException.class) .hasMessageContaining("Internal server error"); } + + @Test + public void testOAuthErrorHandlerInvalidGrant() { + ErrorResponse error = + ErrorResponse.builder() + .responseCode(400) + .withType(OAuth2Properties.INVALID_GRANT_ERROR) + .withMessage("token expired") + .build(); + + assertThatThrownBy(() -> ErrorHandlers.oauthErrorHandler().accept(error)) + .isInstanceOf(OAuth2BadRequestException.class) + .hasMessage("Malformed request: invalid_grant: token expired") + .asInstanceOf(InstanceOfAssertFactories.type(OAuth2BadRequestException.class)) + .extracting(OAuth2BadRequestException::errorType) + .isEqualTo(OAuth2Properties.INVALID_GRANT_ERROR); + } + + @Test + public void testOAuthErrorHandlerInvalidClient() { + ErrorResponse error = + ErrorResponse.builder() + .responseCode(401) + .withType(OAuth2Properties.INVALID_CLIENT_ERROR) + .withMessage("bad credentials") + .build(); + + assertThatThrownBy(() -> ErrorHandlers.oauthErrorHandler().accept(error)) + .isInstanceOf(OAuth2NotAuthorizedException.class) + .hasMessage("Not authorized: invalid_client: bad credentials") + .asInstanceOf(InstanceOfAssertFactories.type(OAuth2NotAuthorizedException.class)) + .extracting(OAuth2NotAuthorizedException::errorType) + .isEqualTo(OAuth2Properties.INVALID_CLIENT_ERROR); + } + + @Test + public void testOAuthErrorHandlerInvalidRequest() { + ErrorResponse error = + ErrorResponse.builder() + .responseCode(400) + .withType(OAuth2Properties.INVALID_REQUEST_ERROR) + .withMessage("missing parameter") + .build(); + + assertThatThrownBy(() -> ErrorHandlers.oauthErrorHandler().accept(error)) + .isInstanceOf(OAuth2BadRequestException.class) + .hasMessage("Malformed request: invalid_request: missing parameter") + .asInstanceOf(InstanceOfAssertFactories.type(OAuth2BadRequestException.class)) + .extracting(OAuth2BadRequestException::errorType) + .isEqualTo(OAuth2Properties.INVALID_REQUEST_ERROR); + } + + @Test + public void testOAuthErrorHandlerUnauthorizedClient() { + ErrorResponse error = + ErrorResponse.builder() + .responseCode(400) + .withType(OAuth2Properties.UNAUTHORIZED_CLIENT_ERROR) + .withMessage("client cannot use this grant") + .build(); + + assertThatThrownBy(() -> ErrorHandlers.oauthErrorHandler().accept(error)) + .isInstanceOf(OAuth2BadRequestException.class) + .hasMessage("Malformed request: unauthorized_client: client cannot use this grant") + .asInstanceOf(InstanceOfAssertFactories.type(OAuth2BadRequestException.class)) + .extracting(OAuth2BadRequestException::errorType) + .isEqualTo(OAuth2Properties.UNAUTHORIZED_CLIENT_ERROR); + } + + @Test + public void testOAuthErrorHandlerUnsupportedGrantType() { + ErrorResponse error = + ErrorResponse.builder() + .responseCode(400) + .withType(OAuth2Properties.UNSUPPORTED_GRANT_TYPE_ERROR) + .withMessage("unsupported grant") + .build(); + + assertThatThrownBy(() -> ErrorHandlers.oauthErrorHandler().accept(error)) + .isInstanceOf(OAuth2BadRequestException.class) + .hasMessage("Malformed request: unsupported_grant_type: unsupported grant") + .asInstanceOf(InstanceOfAssertFactories.type(OAuth2BadRequestException.class)) + .extracting(OAuth2BadRequestException::errorType) + .isEqualTo(OAuth2Properties.UNSUPPORTED_GRANT_TYPE_ERROR); + } + + @Test + public void testOAuthErrorHandlerInvalidScope() { + ErrorResponse error = + ErrorResponse.builder() + .responseCode(400) + .withType(OAuth2Properties.INVALID_SCOPE_ERROR) + .withMessage("scope not allowed") + .build(); + + assertThatThrownBy(() -> ErrorHandlers.oauthErrorHandler().accept(error)) + .isInstanceOf(OAuth2BadRequestException.class) + .hasMessage("Malformed request: invalid_scope: scope not allowed") + .asInstanceOf(InstanceOfAssertFactories.type(OAuth2BadRequestException.class)) + .extracting(OAuth2BadRequestException::errorType) + .isEqualTo(OAuth2Properties.INVALID_SCOPE_ERROR); + } }