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

github-merge-queue[bot] 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 5c2eaa2ce6 fix(auth): make JwtAuthFilter eager-401 with @PermitAll 
opt-out (#5404)
5c2eaa2ce6 is described below

commit 5c2eaa2ce612b2d31df2d751614d17a8a8b1a2cc
Author: Yicong Huang <[email protected]>
AuthorDate: Sat Jun 6 16:45:57 2026 -0700

    fix(auth): make JwtAuthFilter eager-401 with @PermitAll opt-out (#5404)
    
    ### What changes were proposed in this PR?
    
    Replaces the lazy pass-through behavior in `common/auth/JwtAuthFilter`
    with an RFC 6750 eager 401 + `WWW-Authenticate` challenge. Missing
    `Authorization` throws the bare `Bearer` challenge; an invalid token
    throws `error="invalid_token"` so a well-behaved client can distinguish
    "please log in" from "your stored token is invalid, discard it". Valid
    tokens populate a `SecurityContext` exactly as before.
    
    Endpoints that need to serve anonymous traffic opt out with `@PermitAll`
    (JSR-250). This PR audits every resource method in the 5 microservices
    and applies `@PermitAll` where the lazy filter previously made it
    implicit:
    
    - 5x `HealthCheckResource` (liveness probes)
    - `AccessControlResource` `/auth/*` (routing proxy that authenticates
    itself via `parseToken` in the resource body)
    - `LiteLLMProxyResource` `/chat/*`, `LiteLLMModelsResource` `/models`
    (preserve current "gated only by `guiWorkflowWorkspaceCopilotEnabled`"
    behavior; whether these should require an authenticated user is a
    separate hardening decision)
    - `DatasetResource`: 5 endpoints with `/public-*` or `/publicVersion`
    paths, plus `/{did}/cover` and `/{did}/cover-url` which already take
    `@Auth Optional[SessionUser]` for opt-in user context
    
    `UnauthorizedException` + `UnauthorizedExceptionMapper` carry the
    `WWW-Authenticate` challenge to the JAX-RS edge. The exception extends
    `RuntimeException` (not `WebApplicationException`) so unit tests can run
    without a Jersey `RuntimeDelegate` on the classpath, and is constructed
    with `writableStackTrace=false` to avoid `fillInStackTrace` cost on the
    auth hot path.
    
    ### Any related issues, documentation, discussions?
    
    Closes #4901. The earlier attempt at this fix, #4903, was reverted in
    #5025 because expired tokens from `localStorage` were piggy-backed onto
    `/api/config/gui` and broke the login page (#5026). PR #5392 has since
    fixed the frontend to drop expired tokens client-side (`skipWhenExpired:
    true`) and clear the session reactively on 401, so this PR can now ship
    safely.
    
    ### How was this PR tested?
    
    Added `JwtAuthFilterSpec` (header-missing / non-Bearer / invalid-token /
    valid-token; method- and class-level `@PermitAll`; resource-info-absent
    fallback) and `UnauthorizedExceptionMapperSpec`. Updated existing
    service specs to expect 401 (from the filter) instead of 403 (from the
    role filter) for missing-Authorization scenarios, and `*ServiceRunSpec`
    mocks verify the new `UnauthorizedExceptionMapper` registration. `sbt
    Auth/test` + 4 service specs all green; `sbt scalafmtCheckAll` clean.
    Manually tested end-to-end against a running `config-service` build with
    `curl` (missing / bad / valid tokens, `@PermitAll` healthcheck and
    pre-login) and in the browser with a forged JWT in localStorage to
    confirm the eager 401 path interoperates cleanly with PR #5392's 401
    interceptor.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.7)
---
 .github/workflows/build.yml                        |   3 +-
 .../texera/service/AccessControlService.scala      |   8 +-
 .../service/resource/AccessControlResource.scala   |  13 +
 .../service/resource/HealthCheckResource.scala     |   2 +
 .../auth/UnauthorizedExceptionMapperSpec.scala     |  59 ++++
 .../service/AccessControlServiceRunSpec.scala      |   2 +
 .../org/apache/texera/auth/JwtAuthFilter.scala     | 103 +++++--
 .../apache/texera/auth/UnauthorizedException.scala |  57 ++++
 .../org/apache/texera/auth/JwtAuthFilterSpec.scala | 313 +++++++++++++++++++++
 .../service/ComputingUnitManagingService.scala     |   8 +-
 .../service/resource/HealthCheckResource.scala     |   2 +
 .../ComputingUnitManagingServiceRunSpec.scala      |   2 +
 .../org/apache/texera/service/ConfigService.scala  |   8 +-
 .../service/resource/HealthCheckResource.scala     |   2 +
 .../texera/service/ConfigServiceRunSpec.scala      |   2 +
 .../service/resource/ConfigResourceAuthSpec.scala  |  31 +-
 .../org/apache/texera/service/FileService.scala    |   8 +-
 .../texera/service/resource/DatasetResource.scala  |   9 +-
 .../service/resource/HealthCheckResource.scala     |   2 +
 .../texera/service/WorkflowCompilingService.scala  |   3 +-
 .../service/resource/HealthCheckResource.scala     |   2 +
 .../service/WorkflowCompilingServiceRunSpec.scala  |   2 +
 22 files changed, 596 insertions(+), 45 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8954766b57..1c92986628 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -260,7 +260,7 @@ jobs:
         # `WorkflowExecutionService/jacoco` only runs that project's
         # Test config (sbt's `test` task does not transit dependsOn),
         # so common modules' tests are listed explicitly here. Modules
-        # with no tests (Auth, Config) are skipped.
+        # with no tests (Config) are skipped.
         #
         # AMBER_TEST_FILTER=skip-integration tells amber/build.sbt to
         # exclude @org.apache.texera.amber.tags.IntegrationTest specs;
@@ -269,6 +269,7 @@ jobs:
           AMBER_TEST_FILTER: skip-integration
         run: |
           sbt "DAO/jacoco" \
+              "Auth/jacoco" \
               "PyBuilder/jacoco" \
               "WorkflowCore/jacoco" \
               "WorkflowOperator/jacoco" \
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/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
 
b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
index d305bf8eb6..259b9f1f50 100644
--- 
a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
+++ 
b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
@@ -20,6 +20,7 @@ package org.apache.texera.service.resource
 import com.fasterxml.jackson.databind.ObjectMapper
 import com.fasterxml.jackson.module.scala.DefaultScalaModule
 import com.typesafe.scalalogging.LazyLogging
+import jakarta.annotation.security.PermitAll
 import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
 import jakarta.ws.rs.core._
 import jakarta.ws.rs.{Consumes, DELETE, GET, POST, Path, Produces}
@@ -203,8 +204,13 @@ object AccessControlResource extends LazyLogging {
       .orElse(extractTokenFromMultipart(body))
   }
 }
+// The routing proxy authenticates each request itself via parseToken in the
+// resource body (returning 403 on missing/invalid tokens), so it must opt
+// out of the filter's eager 401 check. @PermitAll lets requests reach the
+// resource code, which then performs its own auth.
 @Produces(Array(MediaType.APPLICATION_JSON))
 @Path("/auth")
+@PermitAll
 class AccessControlResource extends LazyLogging {
 
   @GET
@@ -237,7 +243,13 @@ class AccessControlResource extends LazyLogging {
   }
 }
 
+// LiteLLM proxy: gates on `guiWorkflowWorkspaceCopilotEnabled`, not on
+// JWT. Preserve pre-eager-filter behavior (anonymous access permitted when
+// the feature flag is on) by opting out of the filter's eager 401. Whether
+// /chat/* should require an authenticated user is a separate hardening
+// decision tracked outside this PR.
 @Path("/chat")
+@PermitAll
 @Produces(Array(MediaType.APPLICATION_JSON))
 @Consumes(Array(MediaType.APPLICATION_JSON))
 class LiteLLMProxyResource extends LazyLogging {
@@ -310,6 +322,7 @@ class LiteLLMProxyResource extends LazyLogging {
 }
 
 @Path("/models")
+@PermitAll
 @Produces(Array(MediaType.APPLICATION_JSON))
 class LiteLLMModelsResource extends LazyLogging {
 
diff --git 
a/access-control-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
 
b/access-control-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
index 08ecd1cd0c..ef928b4dd1 100644
--- 
a/access-control-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
+++ 
b/access-control-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
@@ -19,10 +19,12 @@
 
 package org.apache.texera.service.resource
 
+import jakarta.annotation.security.PermitAll
 import jakarta.ws.rs.core.MediaType
 import jakarta.ws.rs.{GET, Path, Produces}
 
 @Path("/healthcheck")
+@PermitAll
 @Produces(Array(MediaType.APPLICATION_JSON))
 class HealthCheckResource {
   @GET
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/access-control-service/src/test/scala/org/apache/texera/service/AccessControlServiceRunSpec.scala
 
b/access-control-service/src/test/scala/org/apache/texera/service/AccessControlServiceRunSpec.scala
index 89979ee816..96bfd104d9 100644
--- 
a/access-control-service/src/test/scala/org/apache/texera/service/AccessControlServiceRunSpec.scala
+++ 
b/access-control-service/src/test/scala/org/apache/texera/service/AccessControlServiceRunSpec.scala
@@ -23,6 +23,7 @@ import io.dropwizard.core.setup.Environment
 import io.dropwizard.jersey.setup.JerseyEnvironment
 import io.dropwizard.jetty.MutableServletContextHandler
 import io.dropwizard.jetty.setup.ServletEnvironment
+import org.apache.texera.auth.UnauthorizedExceptionMapper
 import org.apache.texera.service.activity.UserActivityEventListener
 import org.mockito.ArgumentMatchers.isA
 import org.mockito.Mockito.{mock, verify, when}
@@ -44,6 +45,7 @@ class AccessControlServiceRunSpec extends AnyFlatSpec with 
Matchers {
     service.run(mock(classOf[AccessControlServiceConfiguration]), env)
 
     verify(jersey).register(isA(classOf[UserActivityEventListener]))
+    verify(jersey).register(classOf[UnauthorizedExceptionMapper])
     verify(jersey).setUrlPattern("/api/*")
   }
 }
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 cedc86573d..bcc0ae8e1c 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
@@ -21,43 +21,94 @@ package org.apache.texera.auth
 
 import com.typesafe.scalalogging.LazyLogging
 import jakarta.annotation.Priority
+import jakarta.annotation.security.PermitAll
 import jakarta.ws.rs.Priorities
-import jakarta.ws.rs.container.{ContainerRequestContext, 
ContainerRequestFilter}
-import jakarta.ws.rs.core.{HttpHeaders, SecurityContext}
+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
 
-// Must run before Jersey's RolesAllowedRequestFilter (which sits at
-// Priorities.AUTHORIZATION = 2000). Without an explicit @Priority, this
-// filter defaults to Priorities.USER (5000) and would run *after* the
-// role check, so a request bearing a valid JWT would still be rejected
-// because the SecurityContext hasn't been populated yet. Pinning to
-// AUTHENTICATION (1000) restores the standard auth → authz ordering.
+/** JAX-RS request filter that authenticates a Bearer JWT and installs a
+  * [[SessionUser]] security context.
+  *
+  * Failure semantics (RFC 6750):
+  *   - No `Authorization: Bearer …` header: throw `401` with a bare
+  *     `WWW-Authenticate: 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
+  *     anonymous access to public endpoints (healthchecks, public dataset
+  *     reads, the access-control routing proxy that authenticates itself).
+  *   - Header present but token verification / claim extraction fails:
+  *     throw `401` with `error="invalid_token"` always, even on `@PermitAll`
+  *     endpoints — a tampered or stale token is never silently treated as
+  *     anonymous, which would mask the client-side bug that motivated
+  *     #5392.
+  *   - Header present and valid: install a `SecurityContext` whose
+  *     principal is the parsed [[SessionUser]].
+  *
+  * Pinned to `Priorities.AUTHENTICATION` so the SecurityContext is
+  * populated before Jersey's RolesAllowedRequestFilter (AUTHORIZATION,
+  * 2000) inspects it.
+  */
 @Provider
 @Priority(Priorities.AUTHENTICATION)
 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 6184cf545a..31a68e9c5d 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,
@@ -79,6 +84,7 @@ object ComputingUnitManagingService {
   def registerAuthFeatures(environment: Environment): Unit = {
     // 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/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
 
b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
index 08ecd1cd0c..ef928b4dd1 100644
--- 
a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
+++ 
b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
@@ -19,10 +19,12 @@
 
 package org.apache.texera.service.resource
 
+import jakarta.annotation.security.PermitAll
 import jakarta.ws.rs.core.MediaType
 import jakarta.ws.rs.{GET, Path, Produces}
 
 @Path("/healthcheck")
+@PermitAll
 @Produces(Array(MediaType.APPLICATION_JSON))
 class HealthCheckResource {
   @GET
diff --git 
a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala
 
b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala
index d27f5725ac..b189cf3480 100644
--- 
a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala
+++ 
b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala
@@ -22,6 +22,7 @@ package org.apache.texera.service
 import io.dropwizard.auth.{AuthDynamicFeature, AuthValueFactoryProvider}
 import io.dropwizard.core.setup.Environment
 import io.dropwizard.jersey.setup.JerseyEnvironment
+import org.apache.texera.auth.UnauthorizedExceptionMapper
 import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature
 import org.mockito.Mockito.{mock, verify, when}
 import org.scalatest.flatspec.AnyFlatSpec
@@ -40,6 +41,7 @@ class ComputingUnitManagingServiceRunSpec extends AnyFlatSpec 
with Matchers {
     ComputingUnitManagingService.registerAuthFeatures(env)
 
     verify(jersey).register(classOf[RolesAllowedDynamicFeature])
+    verify(jersey).register(classOf[UnauthorizedExceptionMapper])
     
verify(jersey).register(org.mockito.ArgumentMatchers.any(classOf[AuthDynamicFeature]))
     verify(jersey).register(
       
org.mockito.ArgumentMatchers.any(classOf[AuthValueFactoryProvider.Binder[_]])
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 5b2712f26e..112ea3e2e4 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}
@@ -111,6 +116,7 @@ object ConfigService {
   def registerAuthFeatures(environment: Environment): Unit = {
     // 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/resource/HealthCheckResource.scala
 
b/config-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
index 08ecd1cd0c..ef928b4dd1 100644
--- 
a/config-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
+++ 
b/config-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
@@ -19,10 +19,12 @@
 
 package org.apache.texera.service.resource
 
+import jakarta.annotation.security.PermitAll
 import jakarta.ws.rs.core.MediaType
 import jakarta.ws.rs.{GET, Path, Produces}
 
 @Path("/healthcheck")
+@PermitAll
 @Produces(Array(MediaType.APPLICATION_JSON))
 class HealthCheckResource {
   @GET
diff --git 
a/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala
 
b/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala
index 5bbf1ff007..388a48136a 100644
--- 
a/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala
+++ 
b/config-service/src/test/scala/org/apache/texera/service/ConfigServiceRunSpec.scala
@@ -22,6 +22,7 @@ package org.apache.texera.service
 import io.dropwizard.auth.{AuthDynamicFeature, AuthValueFactoryProvider}
 import io.dropwizard.core.setup.Environment
 import io.dropwizard.jersey.setup.JerseyEnvironment
+import org.apache.texera.auth.UnauthorizedExceptionMapper
 import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature
 import org.mockito.Mockito.{mock, verify, when}
 import org.scalatest.flatspec.AnyFlatSpec
@@ -40,6 +41,7 @@ class ConfigServiceRunSpec extends AnyFlatSpec with Matchers {
     ConfigService.registerAuthFeatures(env)
 
     verify(jersey).register(classOf[RolesAllowedDynamicFeature])
+    verify(jersey).register(classOf[UnauthorizedExceptionMapper])
     
verify(jersey).register(org.mockito.ArgumentMatchers.any(classOf[AuthDynamicFeature]))
     verify(jersey).register(
       
org.mockito.ArgumentMatchers.any(classOf[AuthValueFactoryProvider.Binder[_]])
diff --git 
a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
 
b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
index 3475682ef8..da91284334 100644
--- 
a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
+++ 
b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
@@ -26,7 +26,7 @@ import io.dropwizard.testing.junit5.ResourceExtension
 import jakarta.annotation.security.RolesAllowed
 import jakarta.ws.rs.core.MediaType
 import jakarta.ws.rs.{GET, Path, Produces}
-import org.apache.texera.auth.{JwtAuth, JwtAuthFilter}
+import org.apache.texera.auth.{JwtAuth, JwtAuthFilter, 
UnauthorizedExceptionMapper}
 import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
 import org.apache.texera.dao.jooq.generated.tables.pojos.User
 import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature
@@ -39,8 +39,10 @@ import org.scalatest.matchers.should.Matchers
 // without an Authorization header. /config/pre-login is the only @PermitAll
 // endpoint and must answer unauthenticated callers (bootstrap regression 
guard,
 // same shape as the break that caused PR #5049 to be reverted in #5173).
-// /config/gui and /config/user-system are now @RolesAllowed; they must reject
-// anonymous traffic and accept callers with a valid Bearer token.
+// /config/gui and /config/user-system are @RolesAllowed; they must reject
+// anonymous traffic with a 401 (now from JwtAuthFilter's eager check, not
+// from a downstream RolesAllowedRequestFilter 403) and accept callers with a
+// valid Bearer token.
 class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with 
BeforeAndAfterAll {
 
   // Mirror production's mapper: ConfigService bootstraps Dropwizard's default 
mapper
@@ -52,6 +54,7 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with 
Matchers with BeforeAndAft
     .builder()
     .setMapper(testMapper)
     .addProvider(classOf[JwtAuthFilter])
+    .addProvider(classOf[UnauthorizedExceptionMapper])
     .addProvider(classOf[RolesAllowedDynamicFeature])
     .addResource(new ConfigResource)
     .addResource(new ConfigResourceAuthSpec.ProtectedProbe)
@@ -101,9 +104,10 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with 
Matchers with BeforeAndAft
     )
   }
 
-  "GET /config/gui" should "return 403 without an Authorization header" in {
+  "GET /config/gui" should "return 401 with a Bearer challenge without an 
Authorization header" in {
     val response = 
resources.target("/config/gui").request(MediaType.APPLICATION_JSON).get()
-    response.getStatus shouldBe 403
+    response.getStatus shouldBe 401
+    response.getHeaderString("WWW-Authenticate") shouldBe 
JwtAuthFilter.BearerChallenge
   }
 
   it should "return 200 with a valid Bearer token whose role matches 
@RolesAllowed" in {
@@ -132,10 +136,11 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with 
Matchers with BeforeAndAft
     )
   }
 
-  "GET /config/user-system" should "return 403 without an Authorization 
header" in {
+  "GET /config/user-system" should "return 401 with a Bearer challenge without 
an Authorization header" in {
     val response =
       
resources.target("/config/user-system").request(MediaType.APPLICATION_JSON).get()
-    response.getStatus shouldBe 403
+    response.getStatus shouldBe 401
+    response.getHeaderString("WWW-Authenticate") shouldBe 
JwtAuthFilter.BearerChallenge
   }
 
   it should "return 200 with a valid Bearer token whose role matches 
@RolesAllowed" in {
@@ -147,13 +152,15 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with 
Matchers with BeforeAndAft
     response.getStatus shouldBe 200
   }
 
-  "GET an @RolesAllowed probe endpoint" should "return 403 without an 
Authorization header" in {
-    // Sanity: with no SecurityContext set by JwtAuthFilter, 
RolesAllowedDynamicFeature
-    // must reject. Catches the case where the feature is registered but 
somehow
-    // disabled (e.g. swallowed exception during setup).
+  "GET an @RolesAllowed probe endpoint" should "return 401 without an 
Authorization header" in {
+    // Sanity: JwtAuthFilter is now eager — missing Authorization is rejected
+    // by the filter itself with a 401 + Bearer challenge, before
+    // RolesAllowedDynamicFeature ever sees the request. Pre-eager behavior
+    // here was a 403 from the role filter; the test pins the new contract.
     val response =
       resources.target("/auth-probe").request(MediaType.APPLICATION_JSON).get()
-    response.getStatus shouldBe 403
+    response.getStatus shouldBe 401
+    response.getHeaderString("WWW-Authenticate") shouldBe 
JwtAuthFilter.BearerChallenge
   }
 
   it should "return 200 with a valid Bearer token whose role matches 
@RolesAllowed" in {
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 987c2e59d6..6da19a924f 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
@@ -21,7 +21,7 @@ package org.apache.texera.service.resource
 
 import com.typesafe.scalalogging.LazyLogging
 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
@@ -654,6 +654,7 @@ class DatasetResource extends LazyLogging {
   }
 
   @GET
+  @PermitAll
   @Path("/public-presign-download")
   def getPublicPresignedUrl(
       @QueryParam("filePath") encodedUrl: String,
@@ -664,6 +665,7 @@ class DatasetResource extends LazyLogging {
   }
 
   @GET
+  @PermitAll
   @Path("/public-presign-download-s3")
   def getPublicPresignedUrlWithS3(
       @QueryParam("filePath") encodedUrl: String,
@@ -1156,6 +1158,7 @@ class DatasetResource extends LazyLogging {
   }
 
   @GET
+  @PermitAll
   @Path("/{name}/publicVersion/list")
   def getPublicDatasetVersionList(
       @PathParam("name") did: Integer
@@ -1302,6 +1305,7 @@ class DatasetResource extends LazyLogging {
   }
 
   @GET
+  @PermitAll
   @Path("/{did}/publicVersion/{dvid}/rootFileNodes")
   def retrievePublicDatasetVersionRootFileNodes(
       @PathParam("did") did: Integer,
@@ -1322,6 +1326,7 @@ class DatasetResource extends LazyLogging {
   }
 
   @GET
+  @PermitAll
   @Path("/public/{did}")
   def getPublicDataset(
       @PathParam("did") did: Integer
@@ -2146,6 +2151,7 @@ class DatasetResource extends LazyLogging {
     * @return 307 Temporary Redirect to cover image
     */
   @GET
+  @PermitAll
   @Path("/{did}/cover")
   def getDatasetCover(
       @PathParam("did") did: Integer,
@@ -2189,6 +2195,7 @@ class DatasetResource extends LazyLogging {
     * since `<img src>` cannot attach the Authorization header.
     */
   @GET
+  @PermitAll
   @Path("/{did}/cover-url")
   @Produces(Array(MediaType.APPLICATION_JSON))
   def getDatasetCoverUrl(
diff --git 
a/file-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
 
b/file-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
index 08ecd1cd0c..ef928b4dd1 100644
--- 
a/file-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
+++ 
b/file-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
@@ -19,10 +19,12 @@
 
 package org.apache.texera.service.resource
 
+import jakarta.annotation.security.PermitAll
 import jakarta.ws.rs.core.MediaType
 import jakarta.ws.rs.{GET, Path, Produces}
 
 @Path("/healthcheck")
+@PermitAll
 @Produces(Array(MediaType.APPLICATION_JSON))
 class HealthCheckResource {
   @GET
diff --git 
a/workflow-compiling-service/src/main/scala/org/apache/texera/service/WorkflowCompilingService.scala
 
b/workflow-compiling-service/src/main/scala/org/apache/texera/service/WorkflowCompilingService.scala
index 8dc573aaf8..d21fa0a225 100644
--- 
a/workflow-compiling-service/src/main/scala/org/apache/texera/service/WorkflowCompilingService.scala
+++ 
b/workflow-compiling-service/src/main/scala/org/apache/texera/service/WorkflowCompilingService.scala
@@ -26,7 +26,7 @@ import io.dropwizard.core.Application
 import io.dropwizard.core.setup.{Bootstrap, Environment}
 import org.apache.texera.amber.config.StorageConfig
 import org.apache.texera.amber.util.ObjectMapperUtils
-import org.apache.texera.auth.{JwtAuthFilter, SessionUser}
+import org.apache.texera.auth.{JwtAuthFilter, SessionUser, 
UnauthorizedExceptionMapper}
 import org.apache.texera.dao.SqlServer
 import org.apache.texera.service.resource.{HealthCheckResource, 
WorkflowCompilationResource}
 import org.eclipse.jetty.servlet.FilterHolder
@@ -99,6 +99,7 @@ object WorkflowCompilingService {
   def registerAuthFeatures(environment: Environment): Unit = {
     // 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/workflow-compiling-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
 
b/workflow-compiling-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
index 08ecd1cd0c..ef928b4dd1 100644
--- 
a/workflow-compiling-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
+++ 
b/workflow-compiling-service/src/main/scala/org/apache/texera/service/resource/HealthCheckResource.scala
@@ -19,10 +19,12 @@
 
 package org.apache.texera.service.resource
 
+import jakarta.annotation.security.PermitAll
 import jakarta.ws.rs.core.MediaType
 import jakarta.ws.rs.{GET, Path, Produces}
 
 @Path("/healthcheck")
+@PermitAll
 @Produces(Array(MediaType.APPLICATION_JSON))
 class HealthCheckResource {
   @GET
diff --git 
a/workflow-compiling-service/src/test/scala/org/apache/texera/service/WorkflowCompilingServiceRunSpec.scala
 
b/workflow-compiling-service/src/test/scala/org/apache/texera/service/WorkflowCompilingServiceRunSpec.scala
index ff5da1b561..cecc09a4d3 100644
--- 
a/workflow-compiling-service/src/test/scala/org/apache/texera/service/WorkflowCompilingServiceRunSpec.scala
+++ 
b/workflow-compiling-service/src/test/scala/org/apache/texera/service/WorkflowCompilingServiceRunSpec.scala
@@ -22,6 +22,7 @@ package org.apache.texera.service
 import io.dropwizard.auth.{AuthDynamicFeature, AuthValueFactoryProvider}
 import io.dropwizard.core.setup.Environment
 import io.dropwizard.jersey.setup.JerseyEnvironment
+import org.apache.texera.auth.UnauthorizedExceptionMapper
 import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature
 import org.mockito.Mockito.{mock, verify, when}
 import org.scalatest.flatspec.AnyFlatSpec
@@ -40,6 +41,7 @@ class WorkflowCompilingServiceRunSpec extends AnyFlatSpec 
with Matchers {
     WorkflowCompilingService.registerAuthFeatures(env)
 
     verify(jersey).register(classOf[RolesAllowedDynamicFeature])
+    verify(jersey).register(classOf[UnauthorizedExceptionMapper])
     
verify(jersey).register(org.mockito.ArgumentMatchers.any(classOf[AuthDynamicFeature]))
     verify(jersey).register(
       
org.mockito.ArgumentMatchers.any(classOf[AuthValueFactoryProvider.Binder[_]])

Reply via email to