This is an automated email from the ASF dual-hosted git repository.
Yicong-Huang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 6f9f0e355d fix(auth): JwtAuthFilter eager-401 with @PermitAll opt-out
(#4903)
6f9f0e355d is described below
commit 6f9f0e355dfba403b5584d0a8703ff58f43b26e0
Author: Yicong Huang <[email protected]>
AuthorDate: Sun May 10 00:05:35 2026 -0700
fix(auth): JwtAuthFilter eager-401 with @PermitAll opt-out (#4903)
### What changes were proposed in this PR?
Align the 4 microservices' `JwtAuthFilter` with amber's behavior: return
`401` directly from the filter with an RFC 6750 `WWW-Authenticate:
Bearer …` challenge, instead of silently passing through to Dropwizard's
`@Auth` injection.
`@PermitAll` (JSR-250) opts a resource out of the no-header `401`. An
invalid token still fails — a tampered/stale token is never treated as
anonymous. The only existing consumer is
`DatasetResource.getDatasetCover` for anonymous public-dataset reads.
### Any related issues, documentation, discussions?
Closes #4901.
Out of scope: `RolesAllowedDynamicFeature` is registered only in amber.
The 4 microservices' `@RolesAllowed` annotations are currently
decorative; `workflow-compiling-service` registers no auth filter at
all. Worth a separate issue.
### How was this PR tested?
`Auth/test` covers `JwtAuthFilter` (header-missing / non-Bearer /
invalid-token / valid-token; method- and class-level `@PermitAll`;
resource-info-absent fallback) and `UnauthorizedExceptionMapper`
(status, challenge passthrough, no entity body). Format / lint clean;
all 4 microservices recompile.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code
---
.../texera/service/AccessControlService.scala | 8 +-
.../auth/UnauthorizedExceptionMapperSpec.scala | 59 ++++
amber/LICENSE-binary-java | 1 +
common/auth/build.sbt | 1 +
.../org/apache/texera/auth/JwtAuthFilter.scala | 96 +++++--
.../apache/texera/auth/UnauthorizedException.scala | 57 ++++
.../org/apache/texera/auth/JwtAuthFilterSpec.scala | 313 +++++++++++++++++++++
.../service/ComputingUnitManagingService.scala | 8 +-
.../org/apache/texera/service/ConfigService.scala | 8 +-
.../org/apache/texera/service/FileService.scala | 8 +-
.../texera/service/resource/DatasetResource.scala | 7 +-
11 files changed, 541 insertions(+), 25 deletions(-)
diff --git
a/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala
b/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala
index 21d367e2bb..0cb5738419 100644
---
a/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala
+++
b/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala
@@ -24,7 +24,12 @@ import
io.dropwizard.configuration.{EnvironmentVariableSubstitutor, Substituting
import io.dropwizard.core.Application
import io.dropwizard.core.setup.{Bootstrap, Environment}
import org.apache.texera.amber.config.StorageConfig
-import org.apache.texera.auth.{JwtAuthFilter, RequestLoggingFilter,
SessionUser}
+import org.apache.texera.auth.{
+ JwtAuthFilter,
+ RequestLoggingFilter,
+ SessionUser,
+ UnauthorizedExceptionMapper
+}
import org.apache.texera.dao.SqlServer
import org.apache.texera.service.activity.UserActivityEventListener
import org.apache.texera.service.resource.{
@@ -72,6 +77,7 @@ class AccessControlService extends
Application[AccessControlServiceConfiguration
// Register JWT authentication filter
environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter]))
+ environment.jersey.register(classOf[UnauthorizedExceptionMapper])
// Enable @Auth annotation for injecting SessionUser
environment.jersey.register(
diff --git
a/access-control-service/src/test/scala/org/apache/texera/auth/UnauthorizedExceptionMapperSpec.scala
b/access-control-service/src/test/scala/org/apache/texera/auth/UnauthorizedExceptionMapperSpec.scala
new file mode 100644
index 0000000000..cd897c896b
--- /dev/null
+++
b/access-control-service/src/test/scala/org/apache/texera/auth/UnauthorizedExceptionMapperSpec.scala
@@ -0,0 +1,59 @@
+/*
+ * 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.texera.auth
+
+import jakarta.ws.rs.core.HttpHeaders
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class UnauthorizedExceptionMapperSpec extends AnyFlatSpec with Matchers {
+
+ // The mapper sits behind every microservice's `environment.jersey.register(
+ // classOf[UnauthorizedExceptionMapper])` wiring. JwtAuthFilter throws
+ // `UnauthorizedException(challenge)` (covered by JwtAuthFilterSpec); this
+ // spec pins what the mapper turns that exception into when JAX-RS calls
+ // `toResponse` at the edge.
+
+ private val mapper = new UnauthorizedExceptionMapper
+
+ "UnauthorizedExceptionMapper" should "map any UnauthorizedException to HTTP
401" in {
+ val response = mapper.toResponse(new UnauthorizedException("Bearer
realm=\"texera\""))
+ response.getStatus shouldBe 401
+ }
+
+ it should "carry the exception's challenge string verbatim in the
WWW-Authenticate header" in {
+ // The challenge is RFC 6750 §3 syntax. The mapper must not rewrite it —
+ // JwtAuthFilter is the single source of truth for which challenge fires
+ // (Bearer vs. Bearer + invalid_token), and any rewrite here would mask
+ // a regression in the filter.
+ val challenge =
+ """Bearer realm="texera", error="invalid_token", error_description="JWT
verification failed""""
+ val response = mapper.toResponse(new UnauthorizedException(challenge))
+ response.getHeaderString(HttpHeaders.WWW_AUTHENTICATE) shouldBe challenge
+ }
+
+ it should "produce no entity body — only status + challenge header" in {
+ // Browsers and curl expect `WWW-Authenticate` on a body-less 401; an
+ // accidental JSON entity (e.g. via Dropwizard's default error mapper)
+ // would suppress the auth challenge prompt in some clients.
+ val response = mapper.toResponse(new UnauthorizedException("Bearer
realm=\"texera\""))
+ response.hasEntity shouldBe false
+ }
+}
diff --git a/amber/LICENSE-binary-java b/amber/LICENSE-binary-java
index 8fbd626ca4..d413b9f8d2 100644
--- a/amber/LICENSE-binary-java
+++ b/amber/LICENSE-binary-java
@@ -630,6 +630,7 @@ licensed with GPL-2.0 with Classpath Exception)
--------------------------------------------------------------------------------
Scala/Java jars:
+ - jakarta.annotation.jakarta.annotation-api-2.1.1.jar
- jakarta.ws.rs.jakarta.ws.rs-api-3.0.0.jar
- javax.ws.rs.javax.ws.rs-api-2.1.1.jar
- org.jgrapht.jgrapht-core-1.4.0.jar
diff --git a/common/auth/build.sbt b/common/auth/build.sbt
index a33da64fea..24feb1984f 100644
--- a/common/auth/build.sbt
+++ b/common/auth/build.sbt
@@ -57,6 +57,7 @@ libraryDependencies ++= Seq(
"com.typesafe.scala-logging" %% "scala-logging" % "3.9.5", // for
LazyLogging
"org.bitbucket.b_c" % "jose4j" % "0.9.6", // for
jwt parser
"jakarta.ws.rs" % "jakarta.ws.rs-api" % "3.0.0", // for
JwtAuthFilter
+ "jakarta.annotation" % "jakarta.annotation-api" % "2.1.1", // for
@PermitAll opt-out in JwtAuthFilter
"jakarta.servlet" % "jakarta.servlet-api" % "5.0.0" % "provided", // for
RequestLoggingFilter
"org.eclipse.jetty" % "jetty-servlet" % "11.0.24" % "provided", // for
FilterHolder
"org.scalatest" %% "scalatest" % "3.2.17" % Test
diff --git
a/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala
b/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala
index 5698515630..779618726c 100644
--- a/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala
+++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala
@@ -20,35 +20,91 @@
package org.apache.texera.auth
import com.typesafe.scalalogging.LazyLogging
-import jakarta.ws.rs.container.{ContainerRequestContext,
ContainerRequestFilter}
-import jakarta.ws.rs.core.{HttpHeaders, SecurityContext}
+import jakarta.annotation.security.PermitAll
+import jakarta.ws.rs.container.{ContainerRequestContext,
ContainerRequestFilter, ResourceInfo}
+import jakarta.ws.rs.core.{Context, HttpHeaders, SecurityContext}
import jakarta.ws.rs.ext.Provider
import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
import java.security.Principal
+/** JAX-RS request filter that authenticates a Bearer JWT and installs a
+ * [[SessionUser]] security context.
+ *
+ * Failure semantics (RFC 6750 §3):
+ * - No `Authorization: Bearer …` header: throw [[UnauthorizedException]]
+ * carrying a bare `Bearer realm="texera"` challenge — unless the
+ * resource method or class is annotated with `@PermitAll`, in which
+ * case the request continues with no security context. This supports
+ * the `@Auth Optional[SessionUser]` pattern for endpoints that need
+ * to serve anonymous users.
+ * - Header present but token verification / claim extraction fails:
+ * throw [[UnauthorizedException]] with `error="invalid_token"`
+ * always, even on `@PermitAll` endpoints — a tampered or stale token
+ * is never silently treated as anonymous.
+ * - Header present and valid: install a `SecurityContext` whose
+ * principal is the parsed [[SessionUser]].
+ *
+ * HTTP translation (status 401, `WWW-Authenticate` header) is done by
+ * [[UnauthorizedExceptionMapper]], registered alongside this filter in
+ * each service.
+ */
@Provider
class JwtAuthFilter extends ContainerRequestFilter with LazyLogging {
+ @Context
+ private var resourceInfo: ResourceInfo = _
+
override def filter(requestContext: ContainerRequestContext): Unit = {
- val authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)
-
- if (authHeader != null && authHeader.startsWith("Bearer ")) {
- val token = authHeader.substring(7) // Remove "Bearer " prefix
- val userOpt = JwtParser.parseToken(token)
-
- if (userOpt.isPresent) {
- val user = userOpt.get()
- requestContext.setSecurityContext(new SecurityContext {
- override def getUserPrincipal: Principal = user
- override def isUserInRole(role: String): Boolean =
- user.isRoleOf(UserRoleEnum.valueOf(role))
- override def isSecure: Boolean = false
- override def getAuthenticationScheme: String = "Bearer"
- })
- } else {
- logger.warn("Invalid JWT: Unable to parse token")
- }
+ val tokenOpt =
extractBearerToken(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION))
+
+ if (tokenOpt.isEmpty) {
+ if (isPermitAll) return
+ throw new UnauthorizedException(JwtAuthFilter.BearerChallenge)
+ }
+
+ val userOpt = JwtParser.parseToken(tokenOpt.get)
+ if (!userOpt.isPresent) {
+ logger.warn("Invalid JWT: Unable to parse token")
+ throw new UnauthorizedException(JwtAuthFilter.InvalidTokenChallenge)
}
+
+ val user = userOpt.get()
+ requestContext.setSecurityContext(new SecurityContext {
+ override def getUserPrincipal: Principal = user
+ override def isUserInRole(role: String): Boolean =
+ user.isRoleOf(UserRoleEnum.valueOf(role))
+ override def isSecure: Boolean = false
+ override def getAuthenticationScheme: String = "Bearer"
+ })
}
+
+ private def isPermitAll: Boolean = {
+ if (resourceInfo == null) return false
+ val m = resourceInfo.getResourceMethod
+ val c = resourceInfo.getResourceClass
+ (m != null && m.isAnnotationPresent(classOf[PermitAll])) ||
+ (c != null && c.isAnnotationPresent(classOf[PermitAll]))
+ }
+
+ // RFC 7235 §2.1: auth-scheme is case-insensitive and the credentials
+ // follow after 1*SP. Tolerate surrounding whitespace and any
+ // capitalization of "Bearer" so that e.g. `authorization: bearer <jwt>`
+ // is accepted instead of being rejected as a malformed header.
+ private def extractBearerToken(authHeader: String): Option[String] = {
+ if (authHeader == null) return None
+ val parts = authHeader.trim.split("\\s+", 2)
+ if (parts.length != 2 || !parts(0).equalsIgnoreCase("Bearer")) return None
+ val token = parts(1).trim
+ if (token.isEmpty) None else Some(token)
+ }
+}
+
+object JwtAuthFilter {
+ // RFC 6750 §3: bare challenge = "please authenticate". The
+ // `error="invalid_token"` parameter signals "the token you sent is
+ // malformed / expired / signature failed" so a well-behaved client can
+ // discard it instead of retrying.
+ val BearerChallenge: String = "Bearer realm=\"texera\""
+ val InvalidTokenChallenge: String = "Bearer realm=\"texera\",
error=\"invalid_token\""
}
diff --git
a/common/auth/src/main/scala/org/apache/texera/auth/UnauthorizedException.scala
b/common/auth/src/main/scala/org/apache/texera/auth/UnauthorizedException.scala
new file mode 100644
index 0000000000..646f8a1101
--- /dev/null
+++
b/common/auth/src/main/scala/org/apache/texera/auth/UnauthorizedException.scala
@@ -0,0 +1,57 @@
+/*
+ * 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.texera.auth
+
+import jakarta.ws.rs.core.{HttpHeaders, Response}
+import jakarta.ws.rs.ext.{ExceptionMapper, Provider}
+
+/** Carries an RFC 6750 §3 `WWW-Authenticate: Bearer …` challenge to be
+ * returned alongside a `401 Unauthorized` response.
+ *
+ * Extends `RuntimeException` (not `WebApplicationException`) so it can be
+ * constructed without a JAX-RS `RuntimeDelegate` on the classpath, which
+ * keeps unit tests for [[JwtAuthFilter]] independent of any Jersey
+ * implementation. The companion [[UnauthorizedExceptionMapper]] converts
+ * the exception to the actual HTTP response at the JAX-RS edge.
+ *
+ * Constructed with `writableStackTrace = false` because this exception is
+ * thrown on every unauthenticated request and the stack trace is never
+ * inspected — skipping `fillInStackTrace` avoids a per-request CPU hit on
+ * the auth hot path.
+ */
+class UnauthorizedException(val challenge: String)
+ extends RuntimeException(
+ challenge,
+ /* cause = */ null,
+ /* enableSuppression = */ false,
+ /* writableStackTrace = */ false
+ )
+
+/** Maps [[UnauthorizedException]] to a `401` response with the carried
+ * `WWW-Authenticate` challenge header.
+ */
+@Provider
+class UnauthorizedExceptionMapper extends
ExceptionMapper[UnauthorizedException] {
+ override def toResponse(e: UnauthorizedException): Response =
+ Response
+ .status(Response.Status.UNAUTHORIZED)
+ .header(HttpHeaders.WWW_AUTHENTICATE, e.challenge)
+ .build()
+}
diff --git
a/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala
b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala
new file mode 100644
index 0000000000..dfab579d48
--- /dev/null
+++ b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala
@@ -0,0 +1,313 @@
+/*
+ * 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.texera.auth
+
+import jakarta.annotation.security.PermitAll
+import jakarta.ws.rs.container.{ContainerRequestContext, ResourceInfo}
+import jakarta.ws.rs.core.{HttpHeaders, Response, SecurityContext}
+import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
+import org.jose4j.jwt.JwtClaims
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.lang.reflect.{Field, Method}
+import java.util.concurrent.atomic.AtomicReference
+
+class JwtAuthFilterSpec extends AnyFlatSpec with Matchers {
+
+ // Minimal stand-in for ContainerRequestContext. The filter only reads the
+ // Authorization header and writes a SecurityContext; everything else is
+ // unimplemented.
+ private class StubRequestContext(authHeader: String) extends
ContainerRequestContext {
+ val securityContext = new AtomicReference[SecurityContext](null)
+
+ override def getHeaderString(name: String): String =
+ if (name == HttpHeaders.AUTHORIZATION) authHeader else null
+ override def setSecurityContext(context: SecurityContext): Unit =
securityContext.set(context)
+ override def getSecurityContext: SecurityContext = securityContext.get()
+
+ // unused
+ override def abortWith(response: Response): Unit = ()
+ override def getProperty(x$1: String): Object = null
+ override def getPropertyNames: java.util.Collection[String] =
+ java.util.Collections.emptyList()
+ override def setProperty(x$1: String, x$2: Object): Unit = ()
+ override def removeProperty(x$1: String): Unit = ()
+ override def getRequest: jakarta.ws.rs.core.Request = null
+ override def getMethod: String = null
+ override def setMethod(x$1: String): Unit = ()
+ override def getUriInfo: jakarta.ws.rs.core.UriInfo = null
+ override def setRequestUri(x$1: java.net.URI): Unit = ()
+ override def setRequestUri(x$1: java.net.URI, x$2: java.net.URI): Unit = ()
+ override def getHeaders: jakarta.ws.rs.core.MultivaluedMap[String, String]
= null
+ override def getCookies: java.util.Map[String, jakarta.ws.rs.core.Cookie]
= null
+ override def getDate: java.util.Date = null
+ override def getLanguage: java.util.Locale = null
+ override def getLength: Int = 0
+ override def getMediaType: jakarta.ws.rs.core.MediaType = null
+ override def getAcceptableMediaTypes:
java.util.List[jakarta.ws.rs.core.MediaType] = null
+ override def getAcceptableLanguages: java.util.List[java.util.Locale] =
null
+ override def hasEntity: Boolean = false
+ override def getEntityStream: java.io.InputStream = null
+ override def setEntityStream(x$1: java.io.InputStream): Unit = ()
+ }
+
+ // Inject @Context ResourceInfo via reflection so tests can flip annotation
+ // states per-case without spinning up Jersey.
+ private def withResourceInfo(filter: JwtAuthFilter, info: ResourceInfo):
Unit = {
+ val f: Field = classOf[JwtAuthFilter].getDeclaredField("resourceInfo")
+ f.setAccessible(true)
+ f.set(filter, info)
+ }
+
+ private class StubResourceInfo(method: Method, cls: Class[_]) extends
ResourceInfo {
+ override def getResourceMethod: Method = method
+ override def getResourceClass: Class[_] = cls
+ }
+
+ private def methodOf(cls: Class[_], name: String): Method =
+ cls.getDeclaredMethods.find(_.getName == name).get
+
+ private class RequiredAuthResource { def secured(): Unit = () }
+ private class OptionalAuthResource { @PermitAll def cover(): Unit = () }
+ @PermitAll private class OpenResource { def anything(): Unit = () }
+
+ private def buildClaims(): JwtClaims = {
+ val c = new JwtClaims
+ c.setSubject("alice")
+ c.setClaim("userId", 42)
+ c.setClaim("googleId", "g-123")
+ c.setClaim("email", "[email protected]")
+ c.setClaim("role", UserRoleEnum.ADMIN.name)
+ c.setClaim("googleAvatar", "avatar")
+ c.setExpirationTimeMinutesInTheFuture(10f)
+ c
+ }
+
+ // -------------------- challenge constants --------------------
+
+ "JwtAuthFilter constants" should "match RFC 6750 §3 challenge syntax" in {
+ JwtAuthFilter.BearerChallenge shouldBe "Bearer realm=\"texera\""
+ JwtAuthFilter.InvalidTokenChallenge shouldBe "Bearer realm=\"texera\",
error=\"invalid_token\""
+ }
+
+ // -------------------- required-auth method --------------------
+
+ "JwtAuthFilter on a required-auth method" should "throw
UnauthorizedException(BearerChallenge) when no Authorization header is present"
in {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(
+ methodOf(classOf[RequiredAuthResource], "secured"),
+ classOf[RequiredAuthResource]
+ )
+ )
+ val ctx = new StubRequestContext(null)
+ val thrown = the[UnauthorizedException] thrownBy filter.filter(ctx)
+ thrown.challenge shouldBe JwtAuthFilter.BearerChallenge
+ ctx.getSecurityContext shouldBe null
+ }
+
+ it should "throw UnauthorizedException(BearerChallenge) when the header is
not a Bearer token" in {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(
+ methodOf(classOf[RequiredAuthResource], "secured"),
+ classOf[RequiredAuthResource]
+ )
+ )
+ val ctx = new StubRequestContext("Basic abc")
+ val thrown = the[UnauthorizedException] thrownBy filter.filter(ctx)
+ thrown.challenge shouldBe JwtAuthFilter.BearerChallenge
+ }
+
+ it should "throw UnauthorizedException(InvalidTokenChallenge) when the
Bearer token cannot be verified" in {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(
+ methodOf(classOf[RequiredAuthResource], "secured"),
+ classOf[RequiredAuthResource]
+ )
+ )
+ val ctx = new StubRequestContext("Bearer not-a-real-jwt")
+ val thrown = the[UnauthorizedException] thrownBy filter.filter(ctx)
+ thrown.challenge shouldBe JwtAuthFilter.InvalidTokenChallenge
+ }
+
+ it should "install a SecurityContext with the parsed SessionUser when the
token is valid" in {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(
+ methodOf(classOf[RequiredAuthResource], "secured"),
+ classOf[RequiredAuthResource]
+ )
+ )
+ val ctx = new StubRequestContext(s"Bearer
${JwtAuth.jwtToken(buildClaims())}")
+
+ filter.filter(ctx)
+
+ val sc = ctx.getSecurityContext
+ sc should not be null
+ sc.getUserPrincipal.asInstanceOf[SessionUser].getUid shouldBe 42
+ sc.getAuthenticationScheme shouldBe "Bearer"
+ sc.isUserInRole(UserRoleEnum.ADMIN.name) shouldBe true
+ sc.isUserInRole(UserRoleEnum.REGULAR.name) shouldBe false
+ }
+
+ // -------------------- @PermitAll opt-out --------------------
+
+ "JwtAuthFilter on a @PermitAll method" should "let an unauthenticated
request pass through with no SecurityContext" in {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(
+ methodOf(classOf[OptionalAuthResource], "cover"),
+ classOf[OptionalAuthResource]
+ )
+ )
+ val ctx = new StubRequestContext(null)
+ filter.filter(ctx) // must NOT throw
+ ctx.getSecurityContext shouldBe null
+ }
+
+ it should "still throw UnauthorizedException(InvalidTokenChallenge) when a
token is supplied but invalid" in {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(
+ methodOf(classOf[OptionalAuthResource], "cover"),
+ classOf[OptionalAuthResource]
+ )
+ )
+ val ctx = new StubRequestContext("Bearer not-a-real-jwt")
+ val thrown = the[UnauthorizedException] thrownBy filter.filter(ctx)
+ thrown.challenge shouldBe JwtAuthFilter.InvalidTokenChallenge
+ }
+
+ it should "install a SecurityContext when a valid token is supplied" in {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(
+ methodOf(classOf[OptionalAuthResource], "cover"),
+ classOf[OptionalAuthResource]
+ )
+ )
+ val ctx = new StubRequestContext(s"Bearer
${JwtAuth.jwtToken(buildClaims())}")
+ filter.filter(ctx)
+ ctx.getSecurityContext.getUserPrincipal.asInstanceOf[SessionUser].getUid
shouldBe 42
+ }
+
+ "JwtAuthFilter on a class-level @PermitAll" should "honor the class
annotation when the method has none" in {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(methodOf(classOf[OpenResource], "anything"),
classOf[OpenResource])
+ )
+ val ctx = new StubRequestContext(null)
+ filter.filter(ctx) // must NOT throw
+ ctx.getSecurityContext shouldBe null
+ }
+
+ "JwtAuthFilter without resource info" should "default to required-auth
(eager 401)" in {
+ val filter = new JwtAuthFilter
+ // resourceInfo left as null — pre-matching path or test scenario
+ val ctx = new StubRequestContext(null)
+ val thrown = the[UnauthorizedException] thrownBy filter.filter(ctx)
+ thrown.challenge shouldBe JwtAuthFilter.BearerChallenge
+ }
+
+ // -------------------- case-insensitive Bearer scheme --------------------
+
+ // RFC 7235 §2.1: auth-scheme is case-insensitive. The header parser must
+ // accept any capitalization of "Bearer" and tolerate surrounding /
+ // intra-header whitespace.
+ private def filterFor(authHeader: String): StubRequestContext = {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(
+ methodOf(classOf[RequiredAuthResource], "secured"),
+ classOf[RequiredAuthResource]
+ )
+ )
+ val ctx = new StubRequestContext(authHeader)
+ filter.filter(ctx)
+ ctx
+ }
+
+ "JwtAuthFilter Bearer scheme parsing" should "accept lowercase 'bearer'" in {
+ val ctx = filterFor(s"bearer ${JwtAuth.jwtToken(buildClaims())}")
+ ctx.getSecurityContext.getUserPrincipal.asInstanceOf[SessionUser].getUid
shouldBe 42
+ }
+
+ it should "accept uppercase 'BEARER'" in {
+ val ctx = filterFor(s"BEARER ${JwtAuth.jwtToken(buildClaims())}")
+ ctx.getSecurityContext.getUserPrincipal.asInstanceOf[SessionUser].getUid
shouldBe 42
+ }
+
+ it should "accept mixed-case 'BeArEr'" in {
+ val ctx = filterFor(s"BeArEr ${JwtAuth.jwtToken(buildClaims())}")
+ ctx.getSecurityContext.getUserPrincipal.asInstanceOf[SessionUser].getUid
shouldBe 42
+ }
+
+ it should "tolerate leading whitespace before the scheme" in {
+ val ctx = filterFor(s" Bearer ${JwtAuth.jwtToken(buildClaims())}")
+ ctx.getSecurityContext.getUserPrincipal.asInstanceOf[SessionUser].getUid
shouldBe 42
+ }
+
+ it should "tolerate multiple spaces between scheme and token" in {
+ val ctx = filterFor(s"Bearer ${JwtAuth.jwtToken(buildClaims())}")
+ ctx.getSecurityContext.getUserPrincipal.asInstanceOf[SessionUser].getUid
shouldBe 42
+ }
+
+ it should "tolerate trailing whitespace after the token" in {
+ val ctx = filterFor(s"Bearer ${JwtAuth.jwtToken(buildClaims())} ")
+ ctx.getSecurityContext.getUserPrincipal.asInstanceOf[SessionUser].getUid
shouldBe 42
+ }
+
+ it should "reject a Bearer header with no token" in {
+ val filter = new JwtAuthFilter
+ withResourceInfo(
+ filter,
+ new StubResourceInfo(
+ methodOf(classOf[RequiredAuthResource], "secured"),
+ classOf[RequiredAuthResource]
+ )
+ )
+ val ctx = new StubRequestContext("Bearer ")
+ val thrown = the[UnauthorizedException] thrownBy filter.filter(ctx)
+ thrown.challenge shouldBe JwtAuthFilter.BearerChallenge
+ }
+
+ // -------------------- exception is stack-trace-less --------------------
+
+ // UnauthorizedException is thrown on every unauthenticated request and the
+ // stack is never inspected. Ensure fillInStackTrace was suppressed so the
+ // auth hot path does not pay for stack capture.
+ "UnauthorizedException" should "carry no stack trace" in {
+ val e = new UnauthorizedException(JwtAuthFilter.BearerChallenge)
+ e.getStackTrace.length shouldBe 0
+ e.getMessage shouldBe JwtAuthFilter.BearerChallenge
+ }
+}
diff --git
a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala
b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala
index a15ced30a2..ec5169eee3 100644
---
a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala
+++
b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala
@@ -25,7 +25,12 @@ import
io.dropwizard.configuration.{EnvironmentVariableSubstitutor, Substituting
import io.dropwizard.core.Application
import io.dropwizard.core.setup.{Bootstrap, Environment}
import org.apache.texera.amber.config.StorageConfig
-import org.apache.texera.auth.{JwtAuthFilter, RequestLoggingFilter,
SessionUser}
+import org.apache.texera.auth.{
+ JwtAuthFilter,
+ RequestLoggingFilter,
+ SessionUser,
+ UnauthorizedExceptionMapper
+}
import org.apache.texera.dao.SqlServer
import org.apache.texera.service.resource.{
ComputingUnitAccessResource,
@@ -64,6 +69,7 @@ class ComputingUnitManagingService extends
Application[ComputingUnitManagingServ
// Register JWT authentication filter
environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter]))
+ environment.jersey.register(classOf[UnauthorizedExceptionMapper])
// Enable @Auth annotation for injecting SessionUser
environment.jersey.register(
diff --git
a/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala
b/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala
index c787016c27..ae69560781 100644
---
a/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala
+++
b/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala
@@ -26,7 +26,12 @@ import
io.dropwizard.configuration.{EnvironmentVariableSubstitutor, Substituting
import io.dropwizard.core.Application
import io.dropwizard.core.setup.{Bootstrap, Environment}
import org.apache.texera.amber.config.StorageConfig
-import org.apache.texera.auth.{JwtAuthFilter, RequestLoggingFilter,
SessionUser}
+import org.apache.texera.auth.{
+ JwtAuthFilter,
+ RequestLoggingFilter,
+ SessionUser,
+ UnauthorizedExceptionMapper
+}
import org.apache.texera.config.DefaultsConfig
import org.apache.texera.dao.SqlServer
import org.apache.texera.service.resource.{ConfigResource, HealthCheckResource}
@@ -65,6 +70,7 @@ class ConfigService extends
Application[ConfigServiceConfiguration] with LazyLog
// Register JWT authentication filter
environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter]))
+ environment.jersey.register(classOf[UnauthorizedExceptionMapper])
// Enable @Auth annotation for injecting SessionUser
environment.jersey.register(
diff --git
a/file-service/src/main/scala/org/apache/texera/service/FileService.scala
b/file-service/src/main/scala/org/apache/texera/service/FileService.scala
index cc4174682f..64a2f64eba 100644
--- a/file-service/src/main/scala/org/apache/texera/service/FileService.scala
+++ b/file-service/src/main/scala/org/apache/texera/service/FileService.scala
@@ -28,7 +28,12 @@ import io.dropwizard.core.Application
import io.dropwizard.core.setup.{Bootstrap, Environment}
import org.apache.texera.amber.config.StorageConfig
import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
-import org.apache.texera.auth.{JwtAuthFilter, RequestLoggingFilter,
SessionUser}
+import org.apache.texera.auth.{
+ JwtAuthFilter,
+ RequestLoggingFilter,
+ SessionUser,
+ UnauthorizedExceptionMapper
+}
import org.apache.texera.dao.SqlServer
import org.apache.texera.service.`type`.DatasetFileNode
import org.apache.texera.service.`type`.serde.DatasetFileNodeSerializer
@@ -83,6 +88,7 @@ class FileService extends
Application[FileServiceConfiguration] with LazyLogging
// Register JWT authentication filter
environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter]))
+ environment.jersey.register(classOf[UnauthorizedExceptionMapper])
// Enable @Auth annotation for injecting SessionUser
environment.jersey.register(
diff --git
a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
index 46457c9454..d9bb85cf4b 100644
---
a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
+++
b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
@@ -20,7 +20,7 @@
package org.apache.texera.service.resource
import io.dropwizard.auth.Auth
-import jakarta.annotation.security.RolesAllowed
+import jakarta.annotation.security.{PermitAll, RolesAllowed}
import jakarta.ws.rs._
import jakarta.ws.rs.core._
import org.apache.texera.amber.config.StorageConfig
@@ -2142,6 +2142,11 @@ class DatasetResource {
*/
@GET
@Path("/{did}/cover")
+ // Anonymous callers may read covers of public datasets; access checks
+ // below still gate everything else. JwtAuthFilter inspects @PermitAll
+ // to skip its eager 401 when no Bearer header is present, so the
+ // @Auth Optional[SessionUser] parameter is injected as empty.
+ @PermitAll
def getDatasetCover(
@PathParam("did") did: Integer,
@Auth sessionUser: Optional[SessionUser]