This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5404-ba963cfacff458e3489214e1987f1a031a0144b8 in repository https://gitbox.apache.org/repos/asf/texera.git
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[_]])
