Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.{
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
1 change: 1 addition & 0 deletions amber/LICENSE-binary-java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions common/auth/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\""
}
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading