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 21d367e2bb1..0cb5738419e 100644 --- a/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala +++ b/access-control-service/src/main/scala/org/apache/texera/service/AccessControlService.scala @@ -24,7 +24,12 @@ import io.dropwizard.configuration.{EnvironmentVariableSubstitutor, Substituting import io.dropwizard.core.Application import io.dropwizard.core.setup.{Bootstrap, Environment} import org.apache.texera.amber.config.StorageConfig -import org.apache.texera.auth.{JwtAuthFilter, RequestLoggingFilter, SessionUser} +import org.apache.texera.auth.{ + JwtAuthFilter, + RequestLoggingFilter, + SessionUser, + UnauthorizedExceptionMapper +} import org.apache.texera.dao.SqlServer import org.apache.texera.service.activity.UserActivityEventListener import org.apache.texera.service.resource.{ @@ -72,6 +77,7 @@ class AccessControlService extends Application[AccessControlServiceConfiguration // Register JWT authentication filter environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) + environment.jersey.register(classOf[UnauthorizedExceptionMapper]) // Enable @Auth annotation for injecting SessionUser environment.jersey.register( diff --git a/access-control-service/src/test/scala/org/apache/texera/auth/UnauthorizedExceptionMapperSpec.scala b/access-control-service/src/test/scala/org/apache/texera/auth/UnauthorizedExceptionMapperSpec.scala new file mode 100644 index 00000000000..cd897c896ba --- /dev/null +++ b/access-control-service/src/test/scala/org/apache/texera/auth/UnauthorizedExceptionMapperSpec.scala @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.auth + +import jakarta.ws.rs.core.HttpHeaders +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class UnauthorizedExceptionMapperSpec extends AnyFlatSpec with Matchers { + + // The mapper sits behind every microservice's `environment.jersey.register( + // classOf[UnauthorizedExceptionMapper])` wiring. JwtAuthFilter throws + // `UnauthorizedException(challenge)` (covered by JwtAuthFilterSpec); this + // spec pins what the mapper turns that exception into when JAX-RS calls + // `toResponse` at the edge. + + private val mapper = new UnauthorizedExceptionMapper + + "UnauthorizedExceptionMapper" should "map any UnauthorizedException to HTTP 401" in { + val response = mapper.toResponse(new UnauthorizedException("Bearer realm=\"texera\"")) + response.getStatus shouldBe 401 + } + + it should "carry the exception's challenge string verbatim in the WWW-Authenticate header" in { + // The challenge is RFC 6750 §3 syntax. The mapper must not rewrite it — + // JwtAuthFilter is the single source of truth for which challenge fires + // (Bearer vs. Bearer + invalid_token), and any rewrite here would mask + // a regression in the filter. + val challenge = + """Bearer realm="texera", error="invalid_token", error_description="JWT verification failed"""" + val response = mapper.toResponse(new UnauthorizedException(challenge)) + response.getHeaderString(HttpHeaders.WWW_AUTHENTICATE) shouldBe challenge + } + + it should "produce no entity body — only status + challenge header" in { + // Browsers and curl expect `WWW-Authenticate` on a body-less 401; an + // accidental JSON entity (e.g. via Dropwizard's default error mapper) + // would suppress the auth challenge prompt in some clients. + val response = mapper.toResponse(new UnauthorizedException("Bearer realm=\"texera\"")) + response.hasEntity shouldBe false + } +} diff --git a/amber/LICENSE-binary-java b/amber/LICENSE-binary-java index d82f69bd837..56ed1ff916d 100644 --- a/amber/LICENSE-binary-java +++ b/amber/LICENSE-binary-java @@ -630,6 +630,7 @@ licensed with GPL-2.0 with Classpath Exception) -------------------------------------------------------------------------------- Scala/Java jars: + - jakarta.annotation.jakarta.annotation-api-2.1.1.jar - jakarta.ws.rs.jakarta.ws.rs-api-3.0.0.jar - javax.ws.rs.javax.ws.rs-api-2.1.1.jar - org.jgrapht.jgrapht-core-1.4.0.jar diff --git a/common/auth/build.sbt b/common/auth/build.sbt index a33da64fea5..24feb1984f8 100644 --- a/common/auth/build.sbt +++ b/common/auth/build.sbt @@ -57,6 +57,7 @@ libraryDependencies ++= Seq( "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5", // for LazyLogging "org.bitbucket.b_c" % "jose4j" % "0.9.6", // for jwt parser "jakarta.ws.rs" % "jakarta.ws.rs-api" % "3.0.0", // for JwtAuthFilter + "jakarta.annotation" % "jakarta.annotation-api" % "2.1.1", // for @PermitAll opt-out in JwtAuthFilter "jakarta.servlet" % "jakarta.servlet-api" % "5.0.0" % "provided", // for RequestLoggingFilter "org.eclipse.jetty" % "jetty-servlet" % "11.0.24" % "provided", // for FilterHolder "org.scalatest" %% "scalatest" % "3.2.17" % Test diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala b/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala index 56985156302..76274297479 100644 --- a/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala +++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtAuthFilter.scala @@ -20,35 +20,80 @@ package org.apache.texera.auth import com.typesafe.scalalogging.LazyLogging -import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter} -import jakarta.ws.rs.core.{HttpHeaders, SecurityContext} +import jakarta.annotation.security.PermitAll +import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter, ResourceInfo} +import jakarta.ws.rs.core.{Context, HttpHeaders, SecurityContext} import jakarta.ws.rs.ext.Provider import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum import java.security.Principal +/** JAX-RS request filter that authenticates a Bearer JWT and installs a + * [[SessionUser]] security context. + * + * Failure semantics (RFC 6750 §3): + * - No `Authorization: Bearer …` header: throw [[UnauthorizedException]] + * carrying a bare `Bearer realm="texera"` challenge — unless the + * resource method or class is annotated with `@PermitAll`, in which + * case the request continues with no security context. This supports + * the `@Auth Optional[SessionUser]` pattern for endpoints that need + * to serve anonymous users. + * - Header present but token verification / claim extraction fails: + * throw [[UnauthorizedException]] with `error="invalid_token"` + * always, even on `@PermitAll` endpoints — a tampered or stale token + * is never silently treated as anonymous. + * - Header present and valid: install a `SecurityContext` whose + * principal is the parsed [[SessionUser]]. + * + * HTTP translation (status 401, `WWW-Authenticate` header) is done by + * [[UnauthorizedExceptionMapper]], registered alongside this filter in + * each service. + */ @Provider class JwtAuthFilter extends ContainerRequestFilter with LazyLogging { + @Context + private var resourceInfo: ResourceInfo = _ + override def filter(requestContext: ContainerRequestContext): Unit = { val authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION) - if (authHeader != null && authHeader.startsWith("Bearer ")) { - val token = authHeader.substring(7) // Remove "Bearer " prefix - val userOpt = JwtParser.parseToken(token) - - if (userOpt.isPresent) { - val user = userOpt.get() - requestContext.setSecurityContext(new SecurityContext { - override def getUserPrincipal: Principal = user - override def isUserInRole(role: String): Boolean = - user.isRoleOf(UserRoleEnum.valueOf(role)) - override def isSecure: Boolean = false - override def getAuthenticationScheme: String = "Bearer" - }) - } else { - logger.warn("Invalid JWT: Unable to parse token") - } + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + if (isPermitAll) return + throw new UnauthorizedException(JwtAuthFilter.BearerChallenge) + } + + val token = authHeader.substring(7) // Remove "Bearer " prefix + val userOpt = JwtParser.parseToken(token) + 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])) + } +} + +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 00000000000..74375e4e362 --- /dev/null +++ b/common/auth/src/main/scala/org/apache/texera/auth/UnauthorizedException.scala @@ -0,0 +1,46 @@ +/* + * 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. + */ +class UnauthorizedException(val challenge: String) extends RuntimeException(challenge) + +/** 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 00000000000..6944e0d0f63 --- /dev/null +++ b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala @@ -0,0 +1,239 @@ +/* + * 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", "alice@example.com") + 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 + } +} 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 a15ced30a29..ec5169eee39 100644 --- a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala +++ b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala @@ -25,7 +25,12 @@ import io.dropwizard.configuration.{EnvironmentVariableSubstitutor, Substituting import io.dropwizard.core.Application import io.dropwizard.core.setup.{Bootstrap, Environment} import org.apache.texera.amber.config.StorageConfig -import org.apache.texera.auth.{JwtAuthFilter, RequestLoggingFilter, SessionUser} +import org.apache.texera.auth.{ + JwtAuthFilter, + RequestLoggingFilter, + SessionUser, + UnauthorizedExceptionMapper +} import org.apache.texera.dao.SqlServer import org.apache.texera.service.resource.{ ComputingUnitAccessResource, @@ -64,6 +69,7 @@ class ComputingUnitManagingService extends Application[ComputingUnitManagingServ // Register JWT authentication filter environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) + environment.jersey.register(classOf[UnauthorizedExceptionMapper]) // Enable @Auth annotation for injecting SessionUser environment.jersey.register( diff --git a/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala b/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala index c787016c270..ae695607818 100644 --- a/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala +++ b/config-service/src/main/scala/org/apache/texera/service/ConfigService.scala @@ -26,7 +26,12 @@ import io.dropwizard.configuration.{EnvironmentVariableSubstitutor, Substituting import io.dropwizard.core.Application import io.dropwizard.core.setup.{Bootstrap, Environment} import org.apache.texera.amber.config.StorageConfig -import org.apache.texera.auth.{JwtAuthFilter, RequestLoggingFilter, SessionUser} +import org.apache.texera.auth.{ + JwtAuthFilter, + RequestLoggingFilter, + SessionUser, + UnauthorizedExceptionMapper +} import org.apache.texera.config.DefaultsConfig import org.apache.texera.dao.SqlServer import org.apache.texera.service.resource.{ConfigResource, HealthCheckResource} @@ -65,6 +70,7 @@ class ConfigService extends Application[ConfigServiceConfiguration] with LazyLog // Register JWT authentication filter environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter])) + environment.jersey.register(classOf[UnauthorizedExceptionMapper]) // Enable @Auth annotation for injecting SessionUser environment.jersey.register( diff --git a/file-service/src/main/scala/org/apache/texera/service/FileService.scala b/file-service/src/main/scala/org/apache/texera/service/FileService.scala index cc4174682fe..64a2f64eba5 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 7bcd3bb77c3..afabf946913 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -20,7 +20,7 @@ package org.apache.texera.service.resource import io.dropwizard.auth.Auth -import jakarta.annotation.security.RolesAllowed +import jakarta.annotation.security.{PermitAll, RolesAllowed} import jakarta.ws.rs._ import jakarta.ws.rs.core._ import org.apache.texera.amber.config.StorageConfig @@ -2148,6 +2148,11 @@ class DatasetResource { */ @GET @Path("/{did}/cover") + // Anonymous callers may read covers of public datasets; access checks + // below still gate everything else. JwtAuthFilter inspects @PermitAll + // to skip its eager 401 when no Bearer header is present, so the + // @Auth Optional[SessionUser] parameter is injected as empty. + @PermitAll def getDatasetCover( @PathParam("did") did: Integer, @Auth sessionUser: Optional[SessionUser]