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

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


The following commit(s) were added to refs/heads/main by this push:
     new 5edf5ea0bf [#10672] improvement(auth): Return specific error message 
when authentication token is expired (#10673)
5edf5ea0bf is described below

commit 5edf5ea0bf4440aab38c47120f8b96aab0bc61c5
Author: Akshay Thorat <[email protected]>
AuthorDate: Tue Apr 14 03:45:43 2026 -0700

    [#10672] improvement(auth): Return specific error message when 
authentication token is expired (#10673)
    
    ### What changes were proposed in this pull request?
    
    Split the `ExpiredJwtException` / `BadJWTException` catch blocks in
    `StaticSignKeyValidator` and `JwksTokenValidator` to return the specific
    message `"Authentication token is expired"` instead of the generic `"JWT
    parse error"` / `"JWKS JWT validation error"`.
    
    Also adds regression tests (`testValidateExpiredTokenHasCorrectMessage`)
    in both test classes to guard against the message regressing.
    
    ### Why are the changes needed?
    
    Clients receiving a `401 Unauthorized` had no way to distinguish an
    expired token from a malformed or wrongly-signed token. An actionable
    error message lets clients know they should refresh/re-authenticate
    rather than debug a configuration issue.
    
    Fix: #10672
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes — the `401 Unauthorized` error message for an expired JWT changes
    from `"JWT parse error"` (static key) or `"JWKS JWT validation error"`
    (JWKS) to `"Authentication token is expired"`.
    
    ### How was this patch tested?
    
    - Added `testValidateExpiredTokenHasCorrectMessage` to
    `TestStaticSignKeyValidator` and `TestJwksTokenValidator`.
    - All existing and new unit tests pass (`./gradlew
    :server-common:test`).
---
 .../exceptions/TokenExpiredException.java          | 54 +++++++++++++++++++++
 .../service/AuthenticationTimeoutException.java    | 55 ++++++++++++++++++++++
 .../iceberg/service/IcebergExceptionMapper.java    |  6 +++
 .../service/TestIcebergAuthenticationFilter.java   | 25 ++++++++++
 .../service/TestIcebergExceptionMapper.java        |  3 ++
 .../server/authentication/JwksTokenValidator.java  |  4 ++
 .../authentication/StaticSignKeyValidator.java     |  6 ++-
 .../authentication/TestJwksTokenValidator.java     | 50 ++++++++++++++++++++
 .../authentication/TestStaticSignKeyValidator.java |  3 +-
 9 files changed, 203 insertions(+), 3 deletions(-)

diff --git 
a/api/src/main/java/org/apache/gravitino/exceptions/TokenExpiredException.java 
b/api/src/main/java/org/apache/gravitino/exceptions/TokenExpiredException.java
new file mode 100644
index 0000000000..a9b72a160b
--- /dev/null
+++ 
b/api/src/main/java/org/apache/gravitino/exceptions/TokenExpiredException.java
@@ -0,0 +1,54 @@
+/*
+ * 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.gravitino.exceptions;
+
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
+
+/**
+ * Exception thrown when an authentication token has expired. This is a 
subclass of {@link
+ * UnauthorizedException} so that existing catch blocks continue to work, 
while allowing callers to
+ * distinguish expired credentials from other authentication failures.
+ */
+public class TokenExpiredException extends UnauthorizedException {
+
+  /**
+   * Constructs a new exception with the specified detail message.
+   *
+   * @param message the detail message.
+   * @param args the arguments to the message.
+   */
+  @FormatMethod
+  public TokenExpiredException(@FormatString String message, Object... args) {
+    super(message, args);
+  }
+
+  /**
+   * Constructs a new exception with the specified detail message and cause.
+   *
+   * @param cause the cause.
+   * @param message the detail message.
+   * @param args the arguments to the message.
+   */
+  @FormatMethod
+  public TokenExpiredException(Throwable cause, @FormatString String message, 
Object... args) {
+    super(cause, message, args);
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/AuthenticationTimeoutException.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/AuthenticationTimeoutException.java
new file mode 100644
index 0000000000..02eeafea1e
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/AuthenticationTimeoutException.java
@@ -0,0 +1,55 @@
+/*
+ * 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.gravitino.iceberg.service;
+
+import org.apache.iceberg.exceptions.RESTException;
+
+/**
+ * Exception for expired authentication tokens, corresponding to the Iceberg 
REST spec's {@code
+ * AuthenticationTimeoutResponse} (HTTP 419).
+ *
+ * <p>The Iceberg SDK does not ship this exception class, so Gravitino 
provides it to properly map
+ * expired token errors to HTTP 419 per the <a
+ * 
href="https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml#L292";>Iceberg
+ * REST catalog OpenAPI spec</a>.
+ */
+@SuppressWarnings("FormatStringAnnotation")
+public class AuthenticationTimeoutException extends RESTException {
+
+  /**
+   * Constructs a new exception with the specified detail message.
+   *
+   * @param message the detail message
+   * @param args the arguments to the message
+   */
+  public AuthenticationTimeoutException(String message, Object... args) {
+    super(message, args);
+  }
+
+  /**
+   * Constructs a new exception with the specified detail message and cause.
+   *
+   * @param cause the cause
+   * @param message the detail message
+   * @param args the arguments to the message
+   */
+  public AuthenticationTimeoutException(Throwable cause, String message, 
Object... args) {
+    super(cause, message, args);
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
index 438d62fc0d..203b91d760 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
@@ -27,6 +27,7 @@ import javax.ws.rs.ext.ExceptionMapper;
 import javax.ws.rs.ext.Provider;
 import org.apache.gravitino.exceptions.IllegalNameIdentifierException;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
+import org.apache.gravitino.exceptions.TokenExpiredException;
 import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.exceptions.BadRequestException;
@@ -61,6 +62,8 @@ public class IcebergExceptionMapper implements 
ExceptionMapper<Exception> {
           .put(NamespaceNotEmptyException.class, 400)
           .put(NotAuthorizedException.class, 401)
           .put(UnauthorizedException.class, 401)
+          .put(AuthenticationTimeoutException.class, 419)
+          .put(TokenExpiredException.class, 419)
           .put(org.apache.gravitino.exceptions.ForbiddenException.class, 403)
           .put(ForbiddenException.class, 403)
           .put(NotFoundException.class, 404)
@@ -97,6 +100,9 @@ public class IcebergExceptionMapper implements 
ExceptionMapper<Exception> {
    */
   public static Exception convertToIcebergException(Exception e) {
     String message = e.getMessage() != null ? e.getMessage() : "";
+    if (e instanceof TokenExpiredException) {
+      return new AuthenticationTimeoutException("%s", message);
+    }
     if (e instanceof UnauthorizedException) {
       return new NotAuthorizedException("%s", message);
     }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
index 404ba94827..dc7be868c7 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
@@ -26,6 +26,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import javax.servlet.http.HttpServletResponse;
+import org.apache.gravitino.exceptions.TokenExpiredException;
 import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.apache.iceberg.rest.responses.ErrorResponse;
 import org.junit.jupiter.api.Assertions;
@@ -120,4 +121,28 @@ public class TestIcebergAuthenticationFilter {
     Assertions.assertEquals("ServiceFailureException", errorResponse.type());
     Assertions.assertEquals("Server Error", errorResponse.message());
   }
+
+  @Test
+  public void testTokenExpiredExceptionReturns419() throws Exception {
+    IcebergAuthenticationFilter filter = new IcebergAuthenticationFilter();
+
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    StringWriter stringWriter = new StringWriter();
+    PrintWriter printWriter = new PrintWriter(stringWriter);
+    when(response.getWriter()).thenReturn(printWriter);
+
+    filter.sendAuthErrorResponse(
+        response, new TokenExpiredException("Authentication token is 
expired"));
+
+    verify(response).setStatus(419);
+    verify(response).setContentType("application/json");
+    verify(response).setCharacterEncoding("UTF-8");
+
+    printWriter.flush();
+    String json = stringWriter.toString();
+    ErrorResponse errorResponse = MAPPER.readValue(json, ErrorResponse.class);
+    Assertions.assertEquals(419, errorResponse.code());
+    Assertions.assertEquals("AuthenticationTimeoutException", 
errorResponse.type());
+    Assertions.assertEquals("Authentication token is expired", 
errorResponse.message());
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java
index f7766a04d4..dff3ce88ee 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.iceberg.service;
 
 import javax.ws.rs.NotFoundException;
 import javax.ws.rs.core.Response;
+import org.apache.gravitino.exceptions.TokenExpiredException;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.exceptions.CommitFailedException;
 import org.apache.iceberg.exceptions.CommitStateUnknownException;
@@ -49,6 +50,8 @@ public class TestIcebergExceptionMapper {
     checkExceptionStatus(new ValidationException(""), 400);
     checkExceptionStatus(new NamespaceNotEmptyException(""), 400);
     checkExceptionStatus(new NotAuthorizedException(""), 401);
+    checkExceptionStatus(new TokenExpiredException("expired"), 419);
+    checkExceptionStatus(new AuthenticationTimeoutException("expired"), 419);
     checkExceptionStatus(new ForbiddenException(""), 403);
     checkExceptionStatus(new NotFoundException(), 404);
     checkExceptionStatus(new NoSuchNamespaceException(""), 404);
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
index 490e477cd9..cdbf294357 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
@@ -29,6 +29,7 @@ import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.SignedJWT;
 import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
 import com.nimbusds.jwt.proc.DefaultJWTProcessor;
+import com.nimbusds.jwt.proc.ExpiredJWTException;
 import java.net.URL;
 import java.security.Principal;
 import java.util.Collections;
@@ -42,6 +43,7 @@ import org.apache.gravitino.auth.GroupMapper;
 import org.apache.gravitino.auth.GroupMapperFactory;
 import org.apache.gravitino.auth.PrincipalMapper;
 import org.apache.gravitino.auth.PrincipalMapperFactory;
+import org.apache.gravitino.exceptions.TokenExpiredException;
 import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -166,6 +168,8 @@ public class JwksTokenValidator implements 
OAuthTokenValidator {
       }
       return userPrincipal;
 
+    } catch (ExpiredJWTException e) {
+      throw new TokenExpiredException(e, "Authentication token is expired");
     } catch (Exception e) {
       LOG.warn(
           "JWKS JWT validation failed for principal [{}]: {}",
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authentication/StaticSignKeyValidator.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authentication/StaticSignKeyValidator.java
index a08e208bba..efc857041e 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authentication/StaticSignKeyValidator.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authentication/StaticSignKeyValidator.java
@@ -45,6 +45,7 @@ import org.apache.gravitino.auth.GroupMapperFactory;
 import org.apache.gravitino.auth.PrincipalMapper;
 import org.apache.gravitino.auth.PrincipalMapperFactory;
 import org.apache.gravitino.auth.SignatureAlgorithmFamilyType;
+import org.apache.gravitino.exceptions.TokenExpiredException;
 import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -134,8 +135,9 @@ public class StaticSignKeyValidator implements 
OAuthTokenValidator {
         return new UserPrincipal(userPrincipal.getName(), mappedGroups);
       }
       return userPrincipal;
-    } catch (ExpiredJwtException
-        | UnsupportedJwtException
+    } catch (ExpiredJwtException e) {
+      throw new TokenExpiredException(e, "Authentication token is expired");
+    } catch (UnsupportedJwtException
         | MalformedJwtException
         | SignatureException
         | IllegalArgumentException e) {
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
index 0fd8cb6e99..3acbac918e 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
@@ -48,6 +48,7 @@ import java.util.HashMap;
 import java.util.Map;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.exceptions.TokenExpiredException;
 import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -721,4 +722,53 @@ public class TestJwksTokenValidator {
 
     assertEquals("unknown", validator.extractPrincipalForLogging(jwt));
   }
+
+  @Test
+  public void testValidateExpiredTokenThrowsTokenExpiredException() throws 
Exception {
+    RSAKey rsaKey =
+        new 
RSAKeyGenerator(2048).keyID("test-key-id").algorithm(JWSAlgorithm.RS256).generate();
+
+    // Create an expired JWT token
+    JWTClaimsSet claimsSet =
+        new JWTClaimsSet.Builder()
+            .subject("test-user")
+            .audience("test-service")
+            .issuer("https://test-issuer.com";)
+            .expirationTime(Date.from(Instant.now().minusSeconds(3600)))
+            .issueTime(Date.from(Instant.now().minusSeconds(7200)))
+            .build();
+
+    SignedJWT signedJWT =
+        new SignedJWT(
+            new 
JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test-key-id").build(), claimsSet);
+    signedJWT.sign(new RSASSASigner(rsaKey));
+
+    String tokenString = signedJWT.serialize();
+
+    try (MockedStatic<JWKSourceBuilder> mockedBuilder = 
mockStatic(JWKSourceBuilder.class)) {
+      @SuppressWarnings("unchecked")
+      JWKSource<SecurityContext> mockJwkSource = mock(JWKSource.class);
+      @SuppressWarnings("unchecked")
+      JWKSourceBuilder<SecurityContext> mockBuilder = 
mock(JWKSourceBuilder.class);
+
+      mockedBuilder.when(() -> 
JWKSourceBuilder.create(any(URL.class))).thenReturn(mockBuilder);
+      when(mockBuilder.build()).thenReturn(mockJwkSource);
+      when(mockJwkSource.get(any(), any())).thenReturn(Arrays.asList(rsaKey));
+
+      Map<String, String> config = new HashMap<>();
+      config.put(
+          "gravitino.authenticator.oauth.jwksUri", 
"https://test-jwks.com/.well-known/jwks.json";);
+      config.put("gravitino.authenticator.oauth.authority", 
"https://test-issuer.com";);
+      config.put("gravitino.authenticator.oauth.principalFields", "sub");
+      config.put("gravitino.authenticator.oauth.allowSkewSecs", "0");
+
+      validator.initialize(createConfig(config));
+
+      TokenExpiredException exception =
+          assertThrows(
+              TokenExpiredException.class,
+              () -> validator.validateToken(tokenString, "test-service"));
+      assertTrue(exception.getMessage().contains("expired"));
+    }
+  }
 }
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestStaticSignKeyValidator.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestStaticSignKeyValidator.java
index 0156dad6b4..6cf6836169 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestStaticSignKeyValidator.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestStaticSignKeyValidator.java
@@ -39,6 +39,7 @@ import java.util.HashMap;
 import java.util.Map;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.exceptions.TokenExpiredException;
 import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -188,7 +189,7 @@ public class TestStaticSignKeyValidator {
             .compact();
 
     assertThrows(
-        UnauthorizedException.class, () -> validator.validateToken(token, 
serviceAudience));
+        TokenExpiredException.class, () -> validator.validateToken(token, 
serviceAudience));
   }
 
   @Test

Reply via email to