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