From 46bb50217c8731a98c75c255bea523a1b928135d Mon Sep 17 00:00:00 2001 From: Oguzhan Unlu Date: Tue, 19 May 2026 14:02:10 +0300 Subject: [PATCH] API, Core: Add exceptions for OAuth2 token endpoint errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth2 token endpoint failures (RFC 6749 §5.2) currently surface as generic BadRequestException / NotAuthorizedException, with the error type stringified into the exception message. Consumers that triage on the error type must regex-parse the message to recover it unreliably. Introduce exceptions that carry the OAuth2 error type as a field accessible via a marker interface: - OAuth2Error (marker, String errorType()) - OAuth2BadRequestException extends BadRequestException implements OAuth2Error (400) - OAuth2NotAuthorizedException extends NotAuthorizedException implements OAuth2Error (401) OAuthErrorHandler.accept now throws these for all six RFC 6749 §5.2 codes (invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope). Design: Two classes (per HTTP status), not six (per error type). Different reasons within an HTTP status class are unified under one exception per status, consistent with how BadRequestException covers all 400s currently. Backward compatible: getMessage() output is byte-identical and existing catch blocks on BadRequestException / NotAuthorizedException continue to fire. Adds six unit tests that pin which exception is thrown for each OAuth2 error type. --- .../exceptions/OAuth2BadRequestException.java | 49 ++++++++ .../iceberg/exceptions/OAuth2Error.java | 29 +++++ .../OAuth2NotAuthorizedException.java | 49 ++++++++ .../apache/iceberg/rest/ErrorHandlers.java | 10 +- .../iceberg/rest/TestErrorHandlers.java | 106 ++++++++++++++++++ 5 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 api/src/main/java/org/apache/iceberg/exceptions/OAuth2BadRequestException.java create mode 100644 api/src/main/java/org/apache/iceberg/exceptions/OAuth2Error.java create mode 100644 api/src/main/java/org/apache/iceberg/exceptions/OAuth2NotAuthorizedException.java 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); + } }