From 96ad92cec90f18ef76188d9b004cab01cb9a32fc Mon Sep 17 00:00:00 2001 From: a Date: Sat, 13 Jun 2026 09:33:27 +0100 Subject: [PATCH] Add JWT Bearer auth mechanism to populate Quarkus SecurityIdentity --- .../JwtBearerAuthenticationMechanism.java | 61 +++++++++++++ .../identity/JwtTokenIdentityProvider.java | 86 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtBearerAuthenticationMechanism.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtTokenIdentityProvider.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtBearerAuthenticationMechanism.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtBearerAuthenticationMechanism.java new file mode 100644 index 0000000000..2012eb8a7a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtBearerAuthenticationMechanism.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.security.identity; + +import java.util.Collections; +import java.util.Set; + +import io.quarkus.security.credential.TokenCredential; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Quarkus HTTP authentication mechanism for {@code Authorization: Bearer } credentials. It + * delegates token validation to {@link JwtTokenIdentityProvider}. When no Bearer token is present + * it yields no identity (anonymous), so unauthenticated/open paths (e.g. endpoints when login is + * disabled, or X-API-KEY auth) are not disturbed. + */ +@ApplicationScoped +public class JwtBearerAuthenticationMechanism implements HttpAuthenticationMechanism { + + private static final String BEARER_PREFIX = "Bearer "; + + @Override + public Uni authenticate( + RoutingContext context, IdentityProviderManager identityProviderManager) { + String token = bearerToken(context); + if (token == null) { + return Uni.createFrom().nullItem(); + } + return identityProviderManager.authenticate( + new TokenAuthenticationRequest(new TokenCredential(token, "bearer"))); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().item(new ChallengeData(401, "WWW-Authenticate", "Bearer")); + } + + @Override + public Set> getCredentialTypes() { + return Collections.singleton(TokenAuthenticationRequest.class); + } + + private static String bearerToken(RoutingContext context) { + String authz = context.request().getHeader("Authorization"); + if (authz != null + && authz.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + String token = authz.substring(BEARER_PREFIX.length()).trim(); + if (!token.isEmpty()) { + return token; + } + } + return null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtTokenIdentityProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtTokenIdentityProvider.java new file mode 100644 index 0000000000..4321d1f7b5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtTokenIdentityProvider.java @@ -0,0 +1,86 @@ +package stirling.software.proprietary.security.identity; + +import java.util.Map; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.service.JwtServiceInterface; + +/** + * Validates a Bearer {@link TokenAuthenticationRequest} with the application's {@link + * JwtServiceInterface} (jjwt + keystore) and produces a Quarkus {@link SecurityIdentity}. This + * replaces the Spring Security JWT filter that previously populated the SecurityContext; the {@code + * role} claim (e.g. {@code ROLE_ADMIN}) is mapped to Quarkus roles, with the {@code ROLE_} prefix + * also stripped so {@code @RolesAllowed("ADMIN")} matches. + */ +@Slf4j +@ApplicationScoped +public class JwtTokenIdentityProvider implements IdentityProvider { + + private static final String ROLE_PREFIX = "ROLE_"; + + @Inject JwtServiceInterface jwtService; + + @Override + public Class getRequestType() { + return TokenAuthenticationRequest.class; + } + + @Override + public Uni authenticate( + TokenAuthenticationRequest request, AuthenticationRequestContext context) { + String token = request.getToken().getToken(); + // Token validation reads the keystore and parses claims (blocking work), so run it off the + // IO thread. + return context.runBlocking(() -> buildIdentity(token)); + } + + private SecurityIdentity buildIdentity(String token) { + try { + jwtService.validateToken(token); + String username = jwtService.extractUsername(token); + if (username == null || username.isBlank()) { + throw new AuthenticationFailedException("JWT has no subject"); + } + + QuarkusSecurityIdentity.Builder builder = + QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal(username)); + + Map claims = jwtService.extractClaims(token); + Object role = claims == null ? null : claims.get("role"); + if (role != null) { + for (String raw : role.toString().split(",")) { + String r = raw.trim(); + if (r.isEmpty()) { + continue; + } + builder.addRole(r); + if (r.startsWith(ROLE_PREFIX)) { + builder.addRole(r.substring(ROLE_PREFIX.length())); + } + } + } + if (claims != null) { + builder.addAttribute("claims", claims); + } + return builder.build(); + } catch (AuthenticationFailedException e) { + throw e; + } catch (Exception e) { + log.debug("JWT authentication failed: {}", e.getMessage()); + throw new AuthenticationFailedException(e); + } + } +}