I'm playing around with Quarkus JWT, and I want to transfer the security context to a CompletableFuture async supplier, which calls methods that require authorization. In Spring, I don't have this problem, but with Quarkus the security context is not transferred, and calls to protected methods from within an async supplier fail due to authorization concerns.
The error message below mentions @ActivateRequestContext
. Adding that to getTreasureCount()
and getAliBabasTreasureCount()
method bypasses the error, but results in a 401 UNAUTHORIZED exception (maybe that could be expected if the secondary methods are applying auth restrictions and the token doesn't have those permissions/roles, but the token does as proved by both direct REST calls and inspection/augmentation of the token).
- What's the best(-practice) approach, here?
- What's actually happening? It appears the security annotations are applied upon ALL calls to a method, not just from the REST entry.
This is very simple code, and the error occurs in method takeTreasure()
on call getTreasureCount();
:
@RequestScoped
@Path("/api/cave")
public class CaveRestController {
@Inject
private RedisService redis;
@Inject
JsonWebToken accessToken;
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path(value="/authorities")
public Map<String,Object> getPrincipalInfo() {
//accessToken.getClaimNames().forEach(claim -> System.out.printf("Claim: %s, Value: %s%n", claim, accessToken.getClaim(claim)));
Collection<String> authorities = accessToken.getClaimNames();
Map<String,Object> info = new HashMap<>();
info.put("name", accessToken.getSubject());
info.put("authorities", authorities);
return info;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path(value="/thieves-treasure")
@RolesAllowed("treasure-hunter")
public CompletableFuture<TreasureModel> getTreasureCount() {
return getTreasure("thieves-treasure", 1000);
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path(value="/alibaba-treasure")
@PermissionsAllowed("see:alibaba-treasure")
public CompletableFuture<TreasureModel> getAliBabasTreasureCount() {
return getTreasure("alibaba-treasure", 0);
}
@POST
@Path(value="/take-treasure")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@PermissionsAllowed("take:thieves-treasure")
@ActivateRequestContext
public CompletableFuture<Map<String, Integer>> takeTreasure(TreasureModel takeTreasure) {
return CompletableFuture.supplyAsync(() -> {
CompletableFuture<TreasureModel> thievesCountFuture = getTreasureCount(); // ERROR!!!
CompletableFuture<TreasureModel> alibabaCountFuture = getAliBabasTreasureCount();
// Wait for both to complete and retrieve results
CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(thievesCountFuture, alibabaCountFuture);
// Block until all are done
combinedFuture.join();
TreasureModel thievesTreasure = null;
TreasureModel alibabaTreasure = null;
try {
thievesTreasure = thievesCountFuture.get();
alibabaTreasure = alibabaCountFuture.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
if (thievesTreasure.amount() < takeTreasure.amount()) {
throw new IllegalArgumentException("Not enough treasure to take");
}
alibabaTreasure = new TreasureModel(alibabaTreasure.owner(), alibabaTreasure.amount() + takeTreasure.amount());
thievesTreasure = new TreasureModel(thievesTreasure.owner(), thievesTreasure.amount() - takeTreasure.amount());
redis.set(alibabaTreasure.owner(), alibabaTreasure.amount());
redis.set(thievesTreasure.owner(), thievesTreasure.amount());
Map<String, Integer> results = new HashMap<>();
results.put(alibabaTreasure.owner(), alibabaTreasure.amount());
results.put(thievesTreasure.owner(), thievesTreasure.amount());
return results;
});
}
private CompletableFuture<TreasureModel> getTreasure(String key, Integer initialValue) {
return CompletableFuture.supplyAsync(() -> {
try {
return redis.get(key)
.thenApply((value) -> {
if (value.isEmpty()) {
redis.set(key, initialValue);
}
return value.orElse(initialValue);
}).thenApply(amount -> new TreasureModel(key, amount)).get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
});
}
}
Here is the error:
2025-03-26 14:38:12,445 ERROR [io.qua.ver.htt.run.QuarkusErrorHandler] (vert.x-eventloop-thread-3) HTTP Request to /api/cave/take-treasure failed, error id: 29f5d35b-e0ee-4b39-b721-a373813eea8a-1
Exception in CaveRestController.java:71
69 return CompletableFuture.supplyAsync(() -> {
70
→ 71 CompletableFuture<TreasureModel> thievesCountFuture = getTreasureCount();
72 CompletableFuture<TreasureModel> alibabaCountFuture = getAliBabasTreasureCount();
73 : jakarta.enterprise.context.ContextNotActiveException: RequestScoped context was not active when trying to obtain a bean instance for a client proxy of CLASS bean [class=io.quarkus.vertx.http.runtime.CurrentVertxRequest, id=0_6n6EmChCiiDdd8HelptG_A0AE]
- you can activate the request context for a specific method using the @ActivateRequestContext interceptor binding
BTW, I'm using a token augmentor to shape the Auth0 token to Quarkus friendly roles/permissions:
@ApplicationScoped
public class CustomJWTIdentityAugmentor implements SecurityIdentityAugmentor {
@ConfigProperty(name = "app.config.server.auth.auth0.custom-jwt-namespace.roles")
private String customRolesNamespace;
@ConfigProperty(name = "app.config.server.auth.auth0.custom-jwt-namespace.permissions")
private String customPermissionsNamespace;
@Override
public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
//return Uni.createFrom().item(build(identity));
return context.runBlocking(build(identity));
}
private Supplier<SecurityIdentity> build(SecurityIdentity identity) {
if(identity.isAnonymous()) {
return () -> identity;
} else {
// create a new builder and copy principal, attributes, credentials and roles from the original identity
QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity);
Object principal = identity.getPrincipal();
if (principal instanceof JsonWebToken accessToken) {
// Extract custom roles and permissions from the JWT claims
Set<String> customRoles = convertClaimToSet(accessToken.getClaim(customRolesNamespace));
Set<String> permissions = convertClaimToSet(accessToken.getClaim(customPermissionsNamespace));
// Add custom roles and permissions.
if (!customRoles.isEmpty()) {
builder.addRoles(customRoles);
}
if (!permissions.isEmpty()) {
permissions.forEach(permission -> System.out.println("Permission: " + permission));
builder.addPermissionsAsString(permissions);
}
}
return builder::build;
}
}
private Set<String> convertClaimToSet(Object claimValue) {
Set<String> result = new HashSet<>();
if (claimValue instanceof JsonArray jsonArray) {
for (JsonValue jv : jsonArray) {
if (jv.getValueType() == JsonValue.ValueType.STRING) {
result.add(((JsonString) jv).getString());
}
}
} else if (claimValue instanceof Iterable<?> iterable) {
for (Object item : iterable) {
if (item instanceof String) {
result.add((String) item);
}
}
}
return result;
}
}
The permissions are present:
Permission: take:thieves-treasure
Permission: see:thieves-treasure
Permission: see:alibaba-treasure
Lastly, simply returning data works:
@POST
@Path(value="/take-treasure")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@PermissionsAllowed("take:thieves-treasure")
public CompletableFuture<Map<String, Integer>> takeTreasure(TreasureModel takeTreasure) {
return CompletableFuture.supplyAsync(() -> {
Map<String, Integer> map = new HashMap<>();
map.put("test1", 980);
map.put("test2", 20);
return map;
});
}
> Content-Length: 42
>
} [42 bytes data]
* upload completely sent off: 42 bytes
< HTTP/1.1 200 OK
< content-length: 24
< Content-Type: application/json;charset=UTF-8
<
{ [24 bytes data]
100 66 100 24 100 42 4811 8420 --:--:-- --:--:-- --:--:-- 16500
* Connection #0 to host localhost left intact
{
"test2": 20,
"test1": 980
}
I'm playing around with Quarkus JWT, and I want to transfer the security context to a CompletableFuture async supplier, which calls methods that require authorization. In Spring, I don't have this problem, but with Quarkus the security context is not transferred, and calls to protected methods from within an async supplier fail due to authorization concerns.
The error message below mentions @ActivateRequestContext
. Adding that to getTreasureCount()
and getAliBabasTreasureCount()
method bypasses the error, but results in a 401 UNAUTHORIZED exception (maybe that could be expected if the secondary methods are applying auth restrictions and the token doesn't have those permissions/roles, but the token does as proved by both direct REST calls and inspection/augmentation of the token).
- What's the best(-practice) approach, here?
- What's actually happening? It appears the security annotations are applied upon ALL calls to a method, not just from the REST entry.
This is very simple code, and the error occurs in method takeTreasure()
on call getTreasureCount();
:
@RequestScoped
@Path("/api/cave")
public class CaveRestController {
@Inject
private RedisService redis;
@Inject
JsonWebToken accessToken;
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path(value="/authorities")
public Map<String,Object> getPrincipalInfo() {
//accessToken.getClaimNames().forEach(claim -> System.out.printf("Claim: %s, Value: %s%n", claim, accessToken.getClaim(claim)));
Collection<String> authorities = accessToken.getClaimNames();
Map<String,Object> info = new HashMap<>();
info.put("name", accessToken.getSubject());
info.put("authorities", authorities);
return info;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path(value="/thieves-treasure")
@RolesAllowed("treasure-hunter")
public CompletableFuture<TreasureModel> getTreasureCount() {
return getTreasure("thieves-treasure", 1000);
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path(value="/alibaba-treasure")
@PermissionsAllowed("see:alibaba-treasure")
public CompletableFuture<TreasureModel> getAliBabasTreasureCount() {
return getTreasure("alibaba-treasure", 0);
}
@POST
@Path(value="/take-treasure")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@PermissionsAllowed("take:thieves-treasure")
@ActivateRequestContext
public CompletableFuture<Map<String, Integer>> takeTreasure(TreasureModel takeTreasure) {
return CompletableFuture.supplyAsync(() -> {
CompletableFuture<TreasureModel> thievesCountFuture = getTreasureCount(); // ERROR!!!
CompletableFuture<TreasureModel> alibabaCountFuture = getAliBabasTreasureCount();
// Wait for both to complete and retrieve results
CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(thievesCountFuture, alibabaCountFuture);
// Block until all are done
combinedFuture.join();
TreasureModel thievesTreasure = null;
TreasureModel alibabaTreasure = null;
try {
thievesTreasure = thievesCountFuture.get();
alibabaTreasure = alibabaCountFuture.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
if (thievesTreasure.amount() < takeTreasure.amount()) {
throw new IllegalArgumentException("Not enough treasure to take");
}
alibabaTreasure = new TreasureModel(alibabaTreasure.owner(), alibabaTreasure.amount() + takeTreasure.amount());
thievesTreasure = new TreasureModel(thievesTreasure.owner(), thievesTreasure.amount() - takeTreasure.amount());
redis.set(alibabaTreasure.owner(), alibabaTreasure.amount());
redis.set(thievesTreasure.owner(), thievesTreasure.amount());
Map<String, Integer> results = new HashMap<>();
results.put(alibabaTreasure.owner(), alibabaTreasure.amount());
results.put(thievesTreasure.owner(), thievesTreasure.amount());
return results;
});
}
private CompletableFuture<TreasureModel> getTreasure(String key, Integer initialValue) {
return CompletableFuture.supplyAsync(() -> {
try {
return redis.get(key)
.thenApply((value) -> {
if (value.isEmpty()) {
redis.set(key, initialValue);
}
return value.orElse(initialValue);
}).thenApply(amount -> new TreasureModel(key, amount)).get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
});
}
}
Here is the error:
2025-03-26 14:38:12,445 ERROR [io.qua.ver.htt.run.QuarkusErrorHandler] (vert.x-eventloop-thread-3) HTTP Request to /api/cave/take-treasure failed, error id: 29f5d35b-e0ee-4b39-b721-a373813eea8a-1
Exception in CaveRestController.java:71
69 return CompletableFuture.supplyAsync(() -> {
70
→ 71 CompletableFuture<TreasureModel> thievesCountFuture = getTreasureCount();
72 CompletableFuture<TreasureModel> alibabaCountFuture = getAliBabasTreasureCount();
73 : jakarta.enterprise.context.ContextNotActiveException: RequestScoped context was not active when trying to obtain a bean instance for a client proxy of CLASS bean [class=io.quarkus.vertx.http.runtime.CurrentVertxRequest, id=0_6n6EmChCiiDdd8HelptG_A0AE]
- you can activate the request context for a specific method using the @ActivateRequestContext interceptor binding
BTW, I'm using a token augmentor to shape the Auth0 token to Quarkus friendly roles/permissions:
@ApplicationScoped
public class CustomJWTIdentityAugmentor implements SecurityIdentityAugmentor {
@ConfigProperty(name = "app.config.server.auth.auth0.custom-jwt-namespace.roles")
private String customRolesNamespace;
@ConfigProperty(name = "app.config.server.auth.auth0.custom-jwt-namespace.permissions")
private String customPermissionsNamespace;
@Override
public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
//return Uni.createFrom().item(build(identity));
return context.runBlocking(build(identity));
}
private Supplier<SecurityIdentity> build(SecurityIdentity identity) {
if(identity.isAnonymous()) {
return () -> identity;
} else {
// create a new builder and copy principal, attributes, credentials and roles from the original identity
QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity);
Object principal = identity.getPrincipal();
if (principal instanceof JsonWebToken accessToken) {
// Extract custom roles and permissions from the JWT claims
Set<String> customRoles = convertClaimToSet(accessToken.getClaim(customRolesNamespace));
Set<String> permissions = convertClaimToSet(accessToken.getClaim(customPermissionsNamespace));
// Add custom roles and permissions.
if (!customRoles.isEmpty()) {
builder.addRoles(customRoles);
}
if (!permissions.isEmpty()) {
permissions.forEach(permission -> System.out.println("Permission: " + permission));
builder.addPermissionsAsString(permissions);
}
}
return builder::build;
}
}
private Set<String> convertClaimToSet(Object claimValue) {
Set<String> result = new HashSet<>();
if (claimValue instanceof JsonArray jsonArray) {
for (JsonValue jv : jsonArray) {
if (jv.getValueType() == JsonValue.ValueType.STRING) {
result.add(((JsonString) jv).getString());
}
}
} else if (claimValue instanceof Iterable<?> iterable) {
for (Object item : iterable) {
if (item instanceof String) {
result.add((String) item);
}
}
}
return result;
}
}
The permissions are present:
Permission: take:thieves-treasure
Permission: see:thieves-treasure
Permission: see:alibaba-treasure
Lastly, simply returning data works:
@POST
@Path(value="/take-treasure")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@PermissionsAllowed("take:thieves-treasure")
public CompletableFuture<Map<String, Integer>> takeTreasure(TreasureModel takeTreasure) {
return CompletableFuture.supplyAsync(() -> {
Map<String, Integer> map = new HashMap<>();
map.put("test1", 980);
map.put("test2", 20);
return map;
});
}
> Content-Length: 42
>
} [42 bytes data]
* upload completely sent off: 42 bytes
< HTTP/1.1 200 OK
< content-length: 24
< Content-Type: application/json;charset=UTF-8
<
{ [24 bytes data]
100 66 100 24 100 42 4811 8420 --:--:-- --:--:-- --:--:-- 16500
* Connection #0 to host localhost left intact
{
"test2": 20,
"test1": 980
}
Share
Improve this question
edited Mar 27 at 3:33
John Manko
asked Mar 26 at 19:02
John MankoJohn Manko
1,9421 gold badge30 silver badges60 bronze badges
1 Answer
Reset to default 2Quarkus addresses the context propagation in the guide Context Propagation in Quarkus (https://quarkus.io/guides/context-propagation), but I would still like any feedback you have on this topic/issue.
So, changing the code to the following:
@RequestScoped
@Path("/api/cave")
public class RestController {
@Inject
ManagedExecutor executor;
@POST
@Path(value="/take-treasure")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@PermissionsAllowed("take:thieves-treasure")
public CompletableFuture<Map<String, Integer>> takeTreasure(TreasureModel takeTreasure) {
return CompletableFuture.supplyAsync(() -> {
/// CODE HERE
},
executor);
}
}
A little more from ChatGPT:
Below is a comparison of how Quarkus and Spring Boot handle context propagation for security, particularly when running asynchronous code:
Quarkus
Reactive Foundation:
Quarkus is built on top of Vert.x and emphasizes non‑blocking, reactive patterns. As such, the CDI request context—including the security context—is tightly bound to the HTTP request that triggers the call.Explicit Context Propagation Required:
When you offload work to another thread (for example, usingCompletableFuture.supplyAsync
), Quarkus does not automatically propagate the request (and hence the security) context to the new thread. This is why you encountered 401 errors when secured methods (annotated with@RolesAllowed
or@PermissionsAllowed
) were invoked in async code without proper context propagation.Solution via ManagedExecutor or Reactive Types:
To address this, Quarkus recommends using a ManagedExecutor (or reactive types like Uni/Multi) that automatically captures and propagates the request context, including security identity, to asynchronous tasks. This ensures that when your secured methods are called—even from your own code—the required security context is available.Method Security Invocation:
In Quarkus, method-level security (e.g., via@RolesAllowed
) is enforced by interceptors that require an active request context with the proper security identity. If you call a secured method asynchronously without propagating that context, the interceptor won’t see the correct authentication data, and the call will be rejected.
Spring Boot
ThreadLocal-Based Security Context:
Spring Security uses aSecurityContextHolder
that, by default, stores the security context in aThreadLocal
(often anInheritableThreadLocal
). This design means that when asynchronous methods are executed using Spring’s@Async
(or similar mechanisms), the security context is automatically inherited by the new thread—provided that the default executor (or a properly configured one) is used.Automatic Propagation (Usually):
Because the security context is thread-local, asynchronous calls in Spring Boot typically “see” the same security information as the parent thread without any additional configuration. (Note: If you use a custom executor that does not inherit thread-local values, you might need aDelegatingSecurityContextExecutor
to propagate the context.)Method Security Invocation:
As in Quarkus, Spring’s method-level security (using annotations like@PreAuthorize
or@RolesAllowed
) is applied through AOP proxies. When a method is invoked asynchronously, as long as the security context is correctly propagated (which it usually is by default), the security checks will occur as expected—even if the method is called from your own code.
Key Differences
Context Propagation Mechanism:
- Quarkus: Requires explicit context propagation (ManagedExecutor or reactive types) to carry the request and security context to asynchronous threads. Without this, secured methods won’t have the necessary security identity, leading to authentication failures (e.g., 401 errors).
- Spring Boot: Uses a thread-local mechanism that generally propagates the security context automatically to asynchronous threads, so extra steps are not usually necessary.
Asynchronous Execution:
- Quarkus: If you simply offload tasks using
CompletableFuture.supplyAsync
without an executor that propagates context, the security interceptors won’t be able to find the authenticated identity. - Spring Boot: With its default configuration, asynchronous execution (via
@Async
) usually inherits the security context from the parent thread, meaning that method security is enforced as expected.
- Quarkus: If you simply offload tasks using
Internal vs. External Calls:
In both frameworks, security annotations like@RolesAllowed
(or Spring’s@PreAuthorize
) are applied when methods are invoked through proxies. Self‑invocation (calling a secured method from within the same bean) bypasses these interceptors in both Quarkus and Spring Boot.
Summary
Quarkus:
- Pros: Optimized for reactive, non‑blocking patterns; explicit context propagation offers fine control.
- Cons: Requires explicit use of a ManagedExecutor or reactive types to ensure that security context (and other request-scoped contexts) is propagated to asynchronous tasks. Without this, secured methods may return 401 errors.
Spring Boot:
- Pros: Automatically propagates the security context using ThreadLocal storage, making asynchronous security easier to manage in many cases.
- Cons: Relies on ThreadLocal inheritance, which can become tricky if custom executors that don’t inherit thread locals are used.
发布者:admin,转转请注明出处:http://www.yc00.com/questions/1744131075a4559844.html
评论列表(0条)