From 5c78e3e7a3e232151b3ac46048eb945dcc7f4ac0 Mon Sep 17 00:00:00 2001 From: Trey Rudolph Date: Thu, 7 May 2026 11:50:57 -0400 Subject: [PATCH 1/4] feat: add JWT role extraction for RBAC in OAuth2 resource server path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When kafbat is configured with oauth2ResourceServer to validate bearer JWTs, the RBAC authority extractors were not invoked, causing JWT- authenticated requests to bypass role-based access control entirely. This adds a configurable ReactiveJwtAuthenticationConverter that extracts roles and username from JWT claims, matches them against configured RBAC role subjects (provider=OAUTH), and produces an authentication token whose principal implements RbacUser — making the existing RBAC enforcement work seamlessly for bearer token requests. Configuration: auth.oauth2.resource-server-rbac.roles-claim: "ter" auth.oauth2.resource-server-rbac.username-claim: "tei" Closes: https://github.com/kafbat/kafka-ui/issues/1828 Co-Authored-By: Claude --- .../ui/config/auth/OAuthProperties.java | 7 + .../ui/config/auth/OAuthSecurityConfig.java | 20 +- .../auth/RbacJwtAuthenticationToken.java | 30 +++ .../io/kafbat/ui/config/auth/RbacJwtUser.java | 12 + ...bacReactiveJwtAuthenticationConverter.java | 102 ++++++++ .../auth/JwtResourceServerRbacTest.java | 218 ++++++++++++++++++ ...eactiveJwtAuthenticationConverterTest.java | 202 ++++++++++++++++ 7 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 api/src/main/java/io/kafbat/ui/config/auth/RbacJwtAuthenticationToken.java create mode 100644 api/src/main/java/io/kafbat/ui/config/auth/RbacJwtUser.java create mode 100644 api/src/main/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverter.java create mode 100644 api/src/test/java/io/kafbat/ui/config/auth/JwtResourceServerRbacTest.java create mode 100644 api/src/test/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverterTest.java diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthProperties.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthProperties.java index 5c861b7e1..100c5aab7 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthProperties.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthProperties.java @@ -15,6 +15,13 @@ public class OAuthProperties { private Map client = new HashMap<>(); private OAuth2ResourceServerProperties resourceServer = null; + private ResourceServerRbac resourceServerRbac; + + @Data + public static class ResourceServerRbac { + private String rolesClaim; + private String usernameClaim; + } @PostConstruct public void init() { diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index 16b75d48b..3c2fd95f4 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -78,7 +78,8 @@ public SecurityWebFilterChain configure( ReactiveOAuth2AccessTokenResponseClient tokenResponseClient, ReactiveOAuth2UserService oidcUserService, ReactiveOAuth2UserService oauth2UserService, - @Qualifier("oauthWebClient") WebClient webClient + @Qualifier("oauthWebClient") WebClient webClient, + AccessControlService accessControlService ) { log.info("Configuring OAUTH2 authentication."); @@ -109,10 +110,19 @@ public SecurityWebFilterChain configure( if (properties.getResourceServer() != null) { OAuth2ResourceServerProperties resourceServer = properties.getResourceServer(); if (resourceServer.getJwt() != null && resourceServer.getJwt().getJwkSetUri() != null) { - builder.oauth2ResourceServer(c -> c.jwt(j -> - j.jwtDecoder(NimbusReactiveJwtDecoder.withJwkSetUri(resourceServer.getJwt().getJwkSetUri()) - .webClient(webClient) - .build()))); + var jwtDecoder = NimbusReactiveJwtDecoder + .withJwkSetUri(resourceServer.getJwt().getJwkSetUri()) + .webClient(webClient) + .build(); + var rbacProps = properties.getResourceServerRbac(); + + builder.oauth2ResourceServer(c -> c.jwt(j -> { + j.jwtDecoder(jwtDecoder); + if (rbacProps != null) { + j.jwtAuthenticationConverter(new RbacReactiveJwtAuthenticationConverter( + accessControlService, rbacProps.getRolesClaim(), rbacProps.getUsernameClaim())); + } + })); } else if (resourceServer.getOpaquetoken() != null && resourceServer.getOpaquetoken().getIntrospectionUri() != null) { OAuth2ResourceServerProperties.Opaquetoken opaquetoken = resourceServer.getOpaquetoken(); diff --git a/api/src/main/java/io/kafbat/ui/config/auth/RbacJwtAuthenticationToken.java b/api/src/main/java/io/kafbat/ui/config/auth/RbacJwtAuthenticationToken.java new file mode 100644 index 000000000..085c6cbc1 --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/config/auth/RbacJwtAuthenticationToken.java @@ -0,0 +1,30 @@ +package io.kafbat.ui.config.auth; + +import java.util.Collection; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +public class RbacJwtAuthenticationToken extends AbstractAuthenticationToken { + + private final RbacJwtUser principal; + private final Jwt jwt; + + public RbacJwtAuthenticationToken(RbacJwtUser principal, Jwt jwt, + Collection authorities) { + super(authorities); + this.principal = principal; + this.jwt = jwt; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return jwt.getTokenValue(); + } + + @Override + public RbacJwtUser getPrincipal() { + return principal; + } +} diff --git a/api/src/main/java/io/kafbat/ui/config/auth/RbacJwtUser.java b/api/src/main/java/io/kafbat/ui/config/auth/RbacJwtUser.java new file mode 100644 index 000000000..43d51cb14 --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/config/auth/RbacJwtUser.java @@ -0,0 +1,12 @@ +package io.kafbat.ui.config.auth; + +import java.util.Collection; +import org.springframework.security.oauth2.jwt.Jwt; + +public record RbacJwtUser(Jwt jwt, String username, Collection groups) implements RbacUser { + + @Override + public String name() { + return username; + } +} diff --git a/api/src/main/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverter.java b/api/src/main/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverter.java new file mode 100644 index 000000000..5e4a41e25 --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverter.java @@ -0,0 +1,102 @@ +package io.kafbat.ui.config.auth; + +import io.kafbat.ui.model.rbac.Role; +import io.kafbat.ui.model.rbac.provider.Provider; +import io.kafbat.ui.service.rbac.AccessControlService; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import reactor.core.publisher.Mono; + +@Slf4j +@RequiredArgsConstructor +public class RbacReactiveJwtAuthenticationConverter + implements Converter> { + + private final AccessControlService accessControlService; + private final String rolesClaim; + private final String usernameClaim; + + @Override + public Mono convert(Jwt jwt) { + String username = extractUsername(jwt); + Collection tokenRoles = extractTokenRoles(jwt); + + log.debug("JWT principal: [{}], token roles: [{}]", username, tokenRoles); + + Set matchedGroups = matchRbacRoles(username, tokenRoles); + + log.debug("Matched RBAC groups: [{}]", matchedGroups); + + RbacJwtUser rbacUser = new RbacJwtUser(jwt, username, matchedGroups); + var authorities = matchedGroups.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + return Mono.just(new RbacJwtAuthenticationToken(rbacUser, jwt, authorities)); + } + + private String extractUsername(Jwt jwt) { + if (usernameClaim != null) { + Object claim = jwt.getClaim(usernameClaim); + if (claim != null) { + return claim.toString(); + } + } + return jwt.getSubject(); + } + + @SuppressWarnings("unchecked") + private Collection extractTokenRoles(Jwt jwt) { + if (rolesClaim == null) { + return Collections.emptyList(); + } + Object claim = jwt.getClaim(rolesClaim); + if (claim == null) { + return Collections.emptyList(); + } + if (claim instanceof Collection) { + return ((Collection) claim).stream() + .map(Object::toString) + .collect(Collectors.toList()); + } + if (claim instanceof String str) { + return Arrays.asList(str.split(",")); + } + return Collections.emptyList(); + } + + private Set matchRbacRoles(String username, Collection tokenRoles) { + List roles = accessControlService.getRoles(); + + Set usernameMatches = roles.stream() + .filter(r -> r.getSubjects().stream() + .filter(s -> s.getProvider().equals(Provider.OAUTH)) + .filter(s -> "user".equals(s.getType())) + .anyMatch(s -> s.matches(username))) + .map(Role::getName) + .collect(Collectors.toSet()); + + Set roleMatches = roles.stream() + .filter(role -> role.getSubjects().stream() + .filter(s -> s.getProvider().equals(Provider.OAUTH)) + .filter(s -> "role".equals(s.getType())) + .anyMatch(subject -> tokenRoles.stream().anyMatch(subject::matches))) + .map(Role::getName) + .collect(Collectors.toSet()); + + Set combined = new HashSet<>(usernameMatches); + combined.addAll(roleMatches); + return combined; + } +} diff --git a/api/src/test/java/io/kafbat/ui/config/auth/JwtResourceServerRbacTest.java b/api/src/test/java/io/kafbat/ui/config/auth/JwtResourceServerRbacTest.java new file mode 100644 index 000000000..577c95692 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/config/auth/JwtResourceServerRbacTest.java @@ -0,0 +1,218 @@ +package io.kafbat.ui.config.auth; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.kafbat.ui.model.rbac.Permission; +import io.kafbat.ui.model.rbac.Resource; +import io.kafbat.ui.model.rbac.Role; +import io.kafbat.ui.model.rbac.Subject; +import io.kafbat.ui.model.rbac.provider.Provider; +import io.kafbat.ui.service.rbac.AccessControlService; +import io.kafbat.ui.service.rbac.extractor.ProviderAuthorityExtractor; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import reactor.test.StepVerifier; + +class JwtResourceServerRbacTest { + + private static WireMockServer wireMockServer; + private static RSAKey rsaKey; + + @BeforeAll + static void startWireMock() throws Exception { + rsaKey = new RSAKeyGenerator(2048).keyID("test-key").generate(); + wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + wireMockServer.start(); + + wireMockServer.stubFor(get(urlPathEqualTo("/.well-known/jwks.json")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody(new JWKSet(rsaKey.toPublicJWK()).toString()))); + } + + @AfterAll + static void stopWireMock() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + private AccessControlService buildAccessControlService() { + AccessControlService acs = mock(AccessControlService.class); + when(acs.isRbacEnabled()).thenReturn(true); + when(acs.getOauthExtractors()).thenReturn(Collections.emptySet()); + + Role streamingRole = buildRole("streaming-role", "streaming", "role"); + Role adminRole = buildRole("admin-role", "admin@company.com", "user"); + + when(acs.getRoles()).thenReturn(List.of(streamingRole, adminRole)); + return acs; + } + + private ReactiveJwtDecoder buildJwtDecoder() { + return NimbusReactiveJwtDecoder + .withJwkSetUri("http://localhost:" + wireMockServer.port() + "/.well-known/jwks.json") + .build(); + } + + @Test + void validJwtWithMatchingRolesProducesAuthenticatedToken() throws Exception { + AccessControlService acs = buildAccessControlService(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("trey@example.com", List.of("streaming")); + Jwt jwt = decoder.decode(tokenValue).block(); + + assertThat(jwt).isNotNull(); + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + + assertThat(authToken).isNotNull(); + assertThat(authToken.isAuthenticated()).isTrue(); + assertThat(authToken.getPrincipal()).isInstanceOf(RbacJwtUser.class); + + RbacJwtUser principal = (RbacJwtUser) authToken.getPrincipal(); + assertThat(principal.name()).isEqualTo("trey@example.com"); + assertThat(principal.groups()).containsExactly("streaming-role"); + } + + @Test + void validJwtWithUsernameMatchProducesAuthenticatedToken() throws Exception { + AccessControlService acs = buildAccessControlService(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("admin@company.com", List.of()); + Jwt jwt = decoder.decode(tokenValue).block(); + + assertThat(jwt).isNotNull(); + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + + assertThat(authToken).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) authToken.getPrincipal(); + assertThat(principal.name()).isEqualTo("admin@company.com"); + assertThat(principal.groups()).containsExactly("admin-role"); + } + + @Test + void validJwtWithNoMatchingRolesProducesEmptyGroups() throws Exception { + AccessControlService acs = buildAccessControlService(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("nobody@unknown.com", List.of("unmatched")); + Jwt jwt = decoder.decode(tokenValue).block(); + + assertThat(jwt).isNotNull(); + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + + assertThat(authToken).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) authToken.getPrincipal(); + assertThat(principal.groups()).isEmpty(); + } + + @Test + void invalidJwtIsRejectedByDecoder() { + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + StepVerifier.create(decoder.decode("invalid.jwt.token")) + .expectError() + .verify(); + } + + @Test + void jwtWithBothRoleAndUsernameMatchProducesUnionOfGroups() throws Exception { + AccessControlService acs = buildAccessControlService(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("admin@company.com", List.of("streaming")); + Jwt jwt = decoder.decode(tokenValue).block(); + + assertThat(jwt).isNotNull(); + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + + assertThat(authToken).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) authToken.getPrincipal(); + assertThat(principal.groups()).containsExactlyInAnyOrder("streaming-role", "admin-role"); + } + + @Test + void principalImplementsRbacUser() throws Exception { + AccessControlService acs = buildAccessControlService(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("trey@example.com", List.of("streaming")); + Jwt jwt = decoder.decode(tokenValue).block(); + + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + assertThat(authToken.getPrincipal()).isInstanceOf(RbacUser.class); + + RbacUser rbacUser = (RbacUser) authToken.getPrincipal(); + assertThat(rbacUser.name()).isEqualTo("trey@example.com"); + assertThat(rbacUser.groups()).contains("streaming-role"); + } + + private String buildSignedJwt(String username, List roles) throws Exception { + JWSSigner signer = new RSASSASigner(rsaKey); + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .subject(username) + .claim("tei", username) + .claim("ter", roles) + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + 300_000)) + .build(); + + SignedJWT signed = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), + claims); + signed.sign(signer); + return signed.serialize(); + } + + private static Role buildRole(String roleName, String subjectValue, String subjectType) { + Role role = new Role(); + role.setName(roleName); + role.setClusters(List.of("local")); + + Subject subject = new Subject(); + subject.setProvider(Provider.OAUTH); + subject.setType(subjectType); + subject.setValue(subjectValue); + role.setSubjects(List.of(subject)); + + Permission permission = new Permission(); + permission.setResource(Resource.APPLICATIONCONFIG.name()); + permission.setActions(List.of("all")); + role.setPermissions(List.of(permission)); + role.validate(); + return role; + } +} diff --git a/api/src/test/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverterTest.java b/api/src/test/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverterTest.java new file mode 100644 index 000000000..efe1da22e --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverterTest.java @@ -0,0 +1,202 @@ +package io.kafbat.ui.config.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.kafbat.ui.config.YamlPropertySourceFactory; +import io.kafbat.ui.service.rbac.AccessControlService; +import io.kafbat.ui.util.AccessControlServiceMock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@EnableConfigurationProperties(RoleBasedAccessControlProperties.class) +@TestPropertySource( + locations = "classpath:application-roles-definition.yml", + factory = YamlPropertySourceFactory.class +) +class RbacReactiveJwtAuthenticationConverterTest { + + @Autowired + private RoleBasedAccessControlProperties properties; + + private AccessControlService accessControlService; + private RbacReactiveJwtAuthenticationConverter converter; + + @BeforeEach + void setUp() { + accessControlService = new AccessControlServiceMock(properties.getRoles()).getMock(); + converter = new RbacReactiveJwtAuthenticationConverter( + accessControlService, "roles", "username"); + } + + @Test + void extractsRolesFromListClaim() { + Jwt jwt = buildJwt(Map.of( + "username", "someone@example.com", + "roles", List.of("ROLE-ADMIN", "ANOTHER-ROLE") + )); + + AbstractAuthenticationToken token = converter.convert(jwt).block(); + + assertThat(token).isNotNull(); + assertThat(token.getPrincipal()).isInstanceOf(RbacJwtUser.class); + RbacJwtUser principal = (RbacJwtUser) token.getPrincipal(); + assertThat(principal.groups()).contains("admin"); + } + + @Test + void extractsRolesFromCommaSeparatedString() { + Jwt jwt = buildJwt(Map.of( + "username", "someone@example.com", + "roles", "ROLE-ADMIN,ROLE_EDITOR" + )); + + AbstractAuthenticationToken token = converter.convert(jwt).block(); + + assertThat(token).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) token.getPrincipal(); + assertThat(principal.groups()).contains("admin", "editor"); + } + + @Test + void matchesUsernameSubject() { + Jwt jwt = buildJwt(Map.of( + "username", "john@kafka.com", + "roles", List.of() + )); + + AbstractAuthenticationToken token = converter.convert(jwt).block(); + + assertThat(token).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) token.getPrincipal(); + assertThat(principal.groups()).contains("viewer"); + assertThat(principal.name()).isEqualTo("john@kafka.com"); + } + + @Test + void matchesBothUsernameAndRoles() { + Jwt jwt = buildJwt(Map.of( + "username", "john@kafka.com", + "roles", List.of("ROLE-ADMIN") + )); + + AbstractAuthenticationToken token = converter.convert(jwt).block(); + + assertThat(token).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) token.getPrincipal(); + assertThat(principal.groups()).contains("admin", "viewer"); + } + + @Test + void noMatchingRolesReturnsEmptyGroups() { + Jwt jwt = buildJwt(Map.of( + "username", "nobody@unknown.com", + "roles", List.of("UNMATCHED_ROLE") + )); + + AbstractAuthenticationToken token = converter.convert(jwt).block(); + + assertThat(token).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) token.getPrincipal(); + assertThat(principal.groups()).isEmpty(); + } + + @Test + void missingRolesClaimReturnsEmptyGroups() { + Jwt jwt = buildJwt(Map.of( + "username", "nobody@unknown.com" + )); + + AbstractAuthenticationToken token = converter.convert(jwt).block(); + + assertThat(token).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) token.getPrincipal(); + assertThat(principal.groups()).isEmpty(); + } + + @Test + void missingUsernameClaimFallsBackToSub() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("sub-fallback-user@kafka.com") + .claim("roles", List.of("ROLE-ADMIN")) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + + AbstractAuthenticationToken token = converter.convert(jwt).block(); + + assertThat(token).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) token.getPrincipal(); + assertThat(principal.name()).isEqualTo("sub-fallback-user@kafka.com"); + assertThat(principal.groups()).contains("admin", "viewer"); + } + + @Test + void nullRolesClaimConfigStillExtractsUsername() { + var converterNoRoles = new RbacReactiveJwtAuthenticationConverter( + accessControlService, null, "username"); + + Jwt jwt = buildJwt(Map.of( + "username", "john@kafka.com" + )); + + AbstractAuthenticationToken token = converterNoRoles.convert(jwt).block(); + + assertThat(token).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) token.getPrincipal(); + assertThat(principal.name()).isEqualTo("john@kafka.com"); + assertThat(principal.groups()).contains("viewer"); + } + + @Test + void rolesFromSetClaim() { + Jwt jwt = buildJwt(Map.of( + "username", "someone@example.com", + "roles", Set.of("ROLE_EDITOR") + )); + + AbstractAuthenticationToken token = converter.convert(jwt).block(); + + assertThat(token).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) token.getPrincipal(); + assertThat(principal.groups()).contains("editor"); + } + + @Test + void tokenHasCorrectAuthorities() { + Jwt jwt = buildJwt(Map.of( + "username", "john@kafka.com", + "roles", List.of("ROLE-ADMIN") + )); + + AbstractAuthenticationToken token = converter.convert(jwt).block(); + + assertThat(token).isNotNull(); + assertThat(token.isAuthenticated()).isTrue(); + assertThat(token.getAuthorities()).extracting("authority") + .contains("admin", "viewer"); + } + + private Jwt buildJwt(Map claims) { + var builder = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("default-sub") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)); + + claims.forEach(builder::claim); + return builder.build(); + } +} From e785f5c9840140419fee87e4a15904ad22a2ec0d Mon Sep 17 00:00:00 2001 From: Trey Rudolph Date: Thu, 7 May 2026 16:14:41 -0400 Subject: [PATCH 2/4] feat: configurable JWT claim extraction with entity-type matching and default role Support IdPs like Slack Toolbelt where roles live in custom claims (e.g. `ter`) rather than standard `sub`. Adds: - `entity-type-claim` config for coarse-grained access by token type (e.g. all `nebula` service tokens get a `service` RBAC role) - `default-role` fallback when no subject mapping matches - New subject type `entity-type` for RBAC role definitions Co-Authored-By: Claude --- .../ui/config/auth/OAuthProperties.java | 2 + .../ui/config/auth/OAuthSecurityConfig.java | 6 +- ...bacReactiveJwtAuthenticationConverter.java | 51 ++++++- .../auth/JwtResourceServerRbacTest.java | 133 ++++++++++++++++-- ...eactiveJwtAuthenticationConverterTest.java | 4 +- 5 files changed, 173 insertions(+), 23 deletions(-) diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthProperties.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthProperties.java index 100c5aab7..423fb12ff 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthProperties.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthProperties.java @@ -21,6 +21,8 @@ public class OAuthProperties { public static class ResourceServerRbac { private String rolesClaim; private String usernameClaim; + private String entityTypeClaim; + private String defaultRole; } @PostConstruct diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index 3c2fd95f4..96c85a454 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -120,7 +120,11 @@ public SecurityWebFilterChain configure( j.jwtDecoder(jwtDecoder); if (rbacProps != null) { j.jwtAuthenticationConverter(new RbacReactiveJwtAuthenticationConverter( - accessControlService, rbacProps.getRolesClaim(), rbacProps.getUsernameClaim())); + accessControlService, + rbacProps.getRolesClaim(), + rbacProps.getUsernameClaim(), + rbacProps.getEntityTypeClaim(), + rbacProps.getDefaultRole())); } })); } else if (resourceServer.getOpaquetoken() != null diff --git a/api/src/main/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverter.java b/api/src/main/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverter.java index 5e4a41e25..f8cfcc95f 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverter.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverter.java @@ -10,7 +10,6 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; @@ -19,22 +18,43 @@ import reactor.core.publisher.Mono; @Slf4j -@RequiredArgsConstructor public class RbacReactiveJwtAuthenticationConverter implements Converter> { private final AccessControlService accessControlService; private final String rolesClaim; private final String usernameClaim; + private final String entityTypeClaim; + private final String defaultRole; + + public RbacReactiveJwtAuthenticationConverter( + AccessControlService accessControlService, + String rolesClaim, + String usernameClaim, + String entityTypeClaim, + String defaultRole) { + this.accessControlService = accessControlService; + this.rolesClaim = rolesClaim; + this.usernameClaim = usernameClaim; + this.entityTypeClaim = entityTypeClaim; + this.defaultRole = defaultRole; + } @Override public Mono convert(Jwt jwt) { String username = extractUsername(jwt); Collection tokenRoles = extractTokenRoles(jwt); + String entityType = extractEntityType(jwt); - log.debug("JWT principal: [{}], token roles: [{}]", username, tokenRoles); + log.debug("JWT principal: [{}], token roles: [{}], entity type: [{}]", + username, tokenRoles, entityType); - Set matchedGroups = matchRbacRoles(username, tokenRoles); + Set matchedGroups = matchRbacRoles(username, tokenRoles, entityType); + + if (matchedGroups.isEmpty() && defaultRole != null) { + log.debug("No RBAC roles matched, applying default role: [{}]", defaultRole); + matchedGroups = Set.of(defaultRole); + } log.debug("Matched RBAC groups: [{}]", matchedGroups); @@ -76,7 +96,16 @@ private Collection extractTokenRoles(Jwt jwt) { return Collections.emptyList(); } - private Set matchRbacRoles(String username, Collection tokenRoles) { + private String extractEntityType(Jwt jwt) { + if (entityTypeClaim == null) { + return null; + } + Object claim = jwt.getClaim(entityTypeClaim); + return claim != null ? claim.toString() : null; + } + + private Set matchRbacRoles(String username, Collection tokenRoles, + String entityType) { List roles = accessControlService.getRoles(); Set usernameMatches = roles.stream() @@ -95,8 +124,20 @@ private Set matchRbacRoles(String username, Collection tokenRole .map(Role::getName) .collect(Collectors.toSet()); + Set entityTypeMatches = Collections.emptySet(); + if (entityType != null) { + entityTypeMatches = roles.stream() + .filter(role -> role.getSubjects().stream() + .filter(s -> s.getProvider().equals(Provider.OAUTH)) + .filter(s -> "entity-type".equals(s.getType())) + .anyMatch(s -> s.matches(entityType))) + .map(Role::getName) + .collect(Collectors.toSet()); + } + Set combined = new HashSet<>(usernameMatches); combined.addAll(roleMatches); + combined.addAll(entityTypeMatches); return combined; } } diff --git a/api/src/test/java/io/kafbat/ui/config/auth/JwtResourceServerRbacTest.java b/api/src/test/java/io/kafbat/ui/config/auth/JwtResourceServerRbacTest.java index 577c95692..26dc71843 100644 --- a/api/src/test/java/io/kafbat/ui/config/auth/JwtResourceServerRbacTest.java +++ b/api/src/test/java/io/kafbat/ui/config/auth/JwtResourceServerRbacTest.java @@ -75,6 +75,19 @@ private AccessControlService buildAccessControlService() { return acs; } + private AccessControlService buildAccessControlServiceWithEntityType() { + AccessControlService acs = mock(AccessControlService.class); + when(acs.isRbacEnabled()).thenReturn(true); + when(acs.getOauthExtractors()).thenReturn(Collections.emptySet()); + + Role streamingRole = buildRole("streaming-role", "streaming", "role"); + Role adminRole = buildRole("admin-role", "admin@company.com", "user"); + Role serviceRole = buildRole("service-role", "nebula", "entity-type"); + + when(acs.getRoles()).thenReturn(List.of(streamingRole, adminRole, serviceRole)); + return acs; + } + private ReactiveJwtDecoder buildJwtDecoder() { return NimbusReactiveJwtDecoder .withJwkSetUri("http://localhost:" + wireMockServer.port() + "/.well-known/jwks.json") @@ -84,10 +97,10 @@ private ReactiveJwtDecoder buildJwtDecoder() { @Test void validJwtWithMatchingRolesProducesAuthenticatedToken() throws Exception { AccessControlService acs = buildAccessControlService(); - var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", null, null); ReactiveJwtDecoder decoder = buildJwtDecoder(); - String tokenValue = buildSignedJwt("trey@example.com", List.of("streaming")); + String tokenValue = buildSignedJwt("trey@example.com", List.of("streaming"), null); Jwt jwt = decoder.decode(tokenValue).block(); assertThat(jwt).isNotNull(); @@ -105,10 +118,10 @@ void validJwtWithMatchingRolesProducesAuthenticatedToken() throws Exception { @Test void validJwtWithUsernameMatchProducesAuthenticatedToken() throws Exception { AccessControlService acs = buildAccessControlService(); - var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", null, null); ReactiveJwtDecoder decoder = buildJwtDecoder(); - String tokenValue = buildSignedJwt("admin@company.com", List.of()); + String tokenValue = buildSignedJwt("admin@company.com", List.of(), null); Jwt jwt = decoder.decode(tokenValue).block(); assertThat(jwt).isNotNull(); @@ -123,10 +136,10 @@ void validJwtWithUsernameMatchProducesAuthenticatedToken() throws Exception { @Test void validJwtWithNoMatchingRolesProducesEmptyGroups() throws Exception { AccessControlService acs = buildAccessControlService(); - var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", null, null); ReactiveJwtDecoder decoder = buildJwtDecoder(); - String tokenValue = buildSignedJwt("nobody@unknown.com", List.of("unmatched")); + String tokenValue = buildSignedJwt("nobody@unknown.com", List.of("unmatched"), null); Jwt jwt = decoder.decode(tokenValue).block(); assertThat(jwt).isNotNull(); @@ -149,10 +162,10 @@ void invalidJwtIsRejectedByDecoder() { @Test void jwtWithBothRoleAndUsernameMatchProducesUnionOfGroups() throws Exception { AccessControlService acs = buildAccessControlService(); - var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", null, null); ReactiveJwtDecoder decoder = buildJwtDecoder(); - String tokenValue = buildSignedJwt("admin@company.com", List.of("streaming")); + String tokenValue = buildSignedJwt("admin@company.com", List.of("streaming"), null); Jwt jwt = decoder.decode(tokenValue).block(); assertThat(jwt).isNotNull(); @@ -166,10 +179,10 @@ void jwtWithBothRoleAndUsernameMatchProducesUnionOfGroups() throws Exception { @Test void principalImplementsRbacUser() throws Exception { AccessControlService acs = buildAccessControlService(); - var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei"); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", null, null); ReactiveJwtDecoder decoder = buildJwtDecoder(); - String tokenValue = buildSignedJwt("trey@example.com", List.of("streaming")); + String tokenValue = buildSignedJwt("trey@example.com", List.of("streaming"), null); Jwt jwt = decoder.decode(tokenValue).block(); AbstractAuthenticationToken authToken = converter.convert(jwt).block(); @@ -180,19 +193,109 @@ void principalImplementsRbacUser() throws Exception { assertThat(rbacUser.groups()).contains("streaming-role"); } - private String buildSignedJwt(String username, List roles) throws Exception { + @Test + void jwtWithEntityTypeMatchProducesCorrectRole() throws Exception { + AccessControlService acs = buildAccessControlServiceWithEntityType(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", "tet", null); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("svc-account", List.of(), "nebula"); + Jwt jwt = decoder.decode(tokenValue).block(); + + assertThat(jwt).isNotNull(); + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + + assertThat(authToken).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) authToken.getPrincipal(); + assertThat(principal.groups()).containsExactly("service-role"); + } + + @Test + void jwtWithEntityTypeAndRoleMatchProducesUnion() throws Exception { + AccessControlService acs = buildAccessControlServiceWithEntityType(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", "tet", null); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("svc-account", List.of("streaming"), "nebula"); + Jwt jwt = decoder.decode(tokenValue).block(); + + assertThat(jwt).isNotNull(); + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + + assertThat(authToken).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) authToken.getPrincipal(); + assertThat(principal.groups()).containsExactlyInAnyOrder("streaming-role", "service-role"); + } + + @Test + void jwtWithNoMatchFallsBackToDefaultRole() throws Exception { + AccessControlService acs = buildAccessControlService(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", null, "viewer"); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("nobody@unknown.com", List.of("unmatched"), null); + Jwt jwt = decoder.decode(tokenValue).block(); + + assertThat(jwt).isNotNull(); + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + + assertThat(authToken).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) authToken.getPrincipal(); + assertThat(principal.groups()).containsExactly("viewer"); + } + + @Test + void jwtWithNoMatchAndNoDefaultProducesEmptyGroups() throws Exception { + AccessControlService acs = buildAccessControlService(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", null, null); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("nobody@unknown.com", List.of("unmatched"), null); + Jwt jwt = decoder.decode(tokenValue).block(); + + assertThat(jwt).isNotNull(); + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + + assertThat(authToken).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) authToken.getPrincipal(); + assertThat(principal.groups()).isEmpty(); + } + + @Test + void defaultRoleNotAppliedWhenRolesMatch() throws Exception { + AccessControlService acs = buildAccessControlService(); + var converter = new RbacReactiveJwtAuthenticationConverter(acs, "ter", "tei", null, "viewer"); + ReactiveJwtDecoder decoder = buildJwtDecoder(); + + String tokenValue = buildSignedJwt("trey@example.com", List.of("streaming"), null); + Jwt jwt = decoder.decode(tokenValue).block(); + + assertThat(jwt).isNotNull(); + AbstractAuthenticationToken authToken = converter.convert(jwt).block(); + + assertThat(authToken).isNotNull(); + RbacJwtUser principal = (RbacJwtUser) authToken.getPrincipal(); + assertThat(principal.groups()).containsExactly("streaming-role"); + assertThat(principal.groups()).doesNotContain("viewer"); + } + + private String buildSignedJwt(String username, List roles, String entityType) + throws Exception { JWSSigner signer = new RSASSASigner(rsaKey); - JWTClaimsSet claims = new JWTClaimsSet.Builder() + var claimsBuilder = new JWTClaimsSet.Builder() .subject(username) .claim("tei", username) .claim("ter", roles) .issueTime(new Date()) - .expirationTime(new Date(System.currentTimeMillis() + 300_000)) - .build(); + .expirationTime(new Date(System.currentTimeMillis() + 300_000)); + + if (entityType != null) { + claimsBuilder.claim("tet", entityType); + } SignedJWT signed = new SignedJWT( new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), - claims); + claimsBuilder.build()); signed.sign(signer); return signed.serialize(); } diff --git a/api/src/test/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverterTest.java b/api/src/test/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverterTest.java index efe1da22e..b0e89eff3 100644 --- a/api/src/test/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverterTest.java +++ b/api/src/test/java/io/kafbat/ui/config/auth/RbacReactiveJwtAuthenticationConverterTest.java @@ -37,7 +37,7 @@ class RbacReactiveJwtAuthenticationConverterTest { void setUp() { accessControlService = new AccessControlServiceMock(properties.getRoles()).getMock(); converter = new RbacReactiveJwtAuthenticationConverter( - accessControlService, "roles", "username"); + accessControlService, "roles", "username", null, null); } @Test @@ -146,7 +146,7 @@ void missingUsernameClaimFallsBackToSub() { @Test void nullRolesClaimConfigStillExtractsUsername() { var converterNoRoles = new RbacReactiveJwtAuthenticationConverter( - accessControlService, null, "username"); + accessControlService, null, "username", null, null); Jwt jwt = buildJwt(Map.of( "username", "john@kafka.com" From e22e0cf12daccec87ae35b3e9e1682731981e592 Mon Sep 17 00:00:00 2001 From: Trey Rudolph Date: Thu, 28 May 2026 16:13:48 -0400 Subject: [PATCH 3/4] feat: support resource-server-only mode without OAuth2 client registrations When auth.type=OAUTH2 is set but no OAuth2 client providers are configured, the app previously threw "OAuth2 authentication is enabled but no providers specified." This change makes interactive login beans (client registration repository, token response client, user services, logout handler) conditional on having client registrations, allowing kafbat to run purely as an OAuth2 resource server validating bearer JWTs. Co-Authored-By: Claude --- .../ui/config/auth/OAuthSecurityConfig.java | 85 +++++++++++-------- .../logout/OAuthLogoutSuccessHandler.java | 5 +- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index 96c85a454..ed96f2f74 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -59,11 +59,6 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig { private final OAuthProperties properties; - /** - * WebClient configured to use system proxy properties (http.proxyHost/https.proxyHost, - * http.proxyPort/https.proxyPort, http.nonProxyHosts/https.nonProxyHosts). - * Created as a bean to ensure system properties are read after context initialization. - */ @Bean(name = "oauthWebClient") public WebClient oauthWebClient() { return WebClient.builder() @@ -74,39 +69,46 @@ public WebClient oauthWebClient() { @Bean public SecurityWebFilterChain configure( ServerHttpSecurity http, - OAuthLogoutSuccessHandler logoutHandler, - ReactiveOAuth2AccessTokenResponseClient tokenResponseClient, - ReactiveOAuth2UserService oidcUserService, - ReactiveOAuth2UserService oauth2UserService, + Optional logoutHandler, + Optional> tokenResponseClient, + Optional> oidcUserService, + Optional> oauth2UserService, @Qualifier("oauthWebClient") WebClient webClient, AccessControlService accessControlService ) { log.info("Configuring OAUTH2 authentication."); - var oidcAuthManager = - new OidcAuthorizationCodeReactiveAuthenticationManager(tokenResponseClient, oidcUserService); - - oidcAuthManager.setJwtDecoderFactory(clientRegistration -> - NimbusReactiveJwtDecoder.withJwkSetUri(clientRegistration.getProviderDetails().getJwkSetUri()) - .webClient(webClient) - .build()); - - var oauth2AuthManager = - new OAuth2LoginReactiveAuthenticationManager(tokenResponseClient, oauth2UserService); - - var delegatingAuthManager = - new DelegatingReactiveAuthenticationManager(oidcAuthManager, oauth2AuthManager); - var builder = http.authorizeExchange(spec -> spec .pathMatchers(AUTH_WHITELIST) .permitAll() .anyExchange() .authenticated() ) - .oauth2Login(oauth2 -> oauth2.authenticationManager(delegatingAuthManager)) - .logout(spec -> spec.logoutSuccessHandler(logoutHandler)) .csrf(ServerHttpSecurity.CsrfSpec::disable); + if (tokenResponseClient.isPresent() && oidcUserService.isPresent() && oauth2UserService.isPresent()) { + log.info("OAuth2 client registrations found, enabling interactive login."); + + var oidcAuthManager = new OidcAuthorizationCodeReactiveAuthenticationManager( + tokenResponseClient.get(), oidcUserService.get()); + + oidcAuthManager.setJwtDecoderFactory(clientRegistration -> + NimbusReactiveJwtDecoder.withJwkSetUri(clientRegistration.getProviderDetails().getJwkSetUri()) + .webClient(webClient) + .build()); + + var oauth2AuthManager = new OAuth2LoginReactiveAuthenticationManager( + tokenResponseClient.get(), oauth2UserService.get()); + + var delegatingAuthManager = + new DelegatingReactiveAuthenticationManager(oidcAuthManager, oauth2AuthManager); + + builder.oauth2Login(oauth2 -> oauth2.authenticationManager(delegatingAuthManager)); + logoutHandler.ifPresent(handler -> builder.logout(spec -> spec.logoutSuccessHandler(handler))); + } else { + log.info("No OAuth2 client registrations, running in resource-server-only mode."); + } + if (properties.getResourceServer() != null) { OAuth2ResourceServerProperties resourceServer = properties.getResourceServer(); if (resourceServer.getJwt() != null && resourceServer.getJwt().getJwkSetUri() != null) { @@ -147,6 +149,9 @@ public SecurityWebFilterChain configure( @Bean public ReactiveOAuth2AccessTokenResponseClient authorizationCodeTokenResponseClient(@Qualifier("oauthWebClient") WebClient webClient) { + if (!hasClientRegistrations()) { + return null; + } var client = new WebClientReactiveAuthorizationCodeTokenResponseClient(); client.setWebClient(webClient); return client; @@ -155,10 +160,12 @@ public SecurityWebFilterChain configure( @Bean public ReactiveOAuth2UserService customOidcUserService( AccessControlService acs, - ReactiveOAuth2UserService oauth2UserService) { + Optional> oauth2UserService) { + if (!hasClientRegistrations() || oauth2UserService.isEmpty()) { + return null; + } final OidcReactiveOAuth2UserService delegate = new OidcReactiveOAuth2UserService(); - - delegate.setOauth2UserService(oauth2UserService); + delegate.setOauth2UserService(oauth2UserService.get()); return request -> delegate.loadUser(request) .flatMap(user -> { @@ -176,6 +183,9 @@ public ReactiveOAuth2UserService customOidcUserServic @Bean public ReactiveOAuth2UserService customOauth2UserService( AccessControlService acs, @Qualifier("oauthWebClient") WebClient webClient) { + if (!hasClientRegistrations()) { + return null; + } final DefaultReactiveOAuth2UserService delegate = new DefaultReactiveOAuth2UserService(); delegate.setWebClient(webClient); @@ -194,18 +204,26 @@ public ReactiveOAuth2UserService customOauth2User @Bean public InMemoryReactiveClientRegistrationRepository clientRegistrationRepository() { + if (!hasClientRegistrations()) { + return null; + } final OAuth2ClientProperties props = OAuthPropertiesConverter.convertProperties(properties); final List registrations = new ArrayList<>(new OAuth2ClientPropertiesMapper(props).asClientRegistrations().values()); - if (registrations.isEmpty()) { - throw new IllegalArgumentException("OAuth2 authentication is enabled but no providers specified."); - } return new InMemoryReactiveClientRegistrationRepository(registrations); } @Bean - public ServerLogoutSuccessHandler defaultOidcLogoutHandler(final ReactiveClientRegistrationRepository repository) { - return new OidcClientInitiatedServerLogoutSuccessHandler(repository); + public ServerLogoutSuccessHandler defaultOidcLogoutHandler( + Optional repository) { + if (repository.isEmpty()) { + return null; + } + return new OidcClientInitiatedServerLogoutSuccessHandler(repository.get()); + } + + private boolean hasClientRegistrations() { + return properties.getClient() != null && !properties.getClient().isEmpty(); } private ProviderAuthorityExtractor getExtractor(final OAuthProperties.OAuth2Provider provider, @@ -223,4 +241,3 @@ private OAuthProperties.OAuth2Provider getProviderByProviderId(final String prov } } - diff --git a/api/src/main/java/io/kafbat/ui/config/auth/logout/OAuthLogoutSuccessHandler.java b/api/src/main/java/io/kafbat/ui/config/auth/logout/OAuthLogoutSuccessHandler.java index 4deb28b1b..e2ddb3fc9 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/logout/OAuthLogoutSuccessHandler.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/logout/OAuthLogoutSuccessHandler.java @@ -3,6 +3,7 @@ import io.kafbat.ui.config.auth.OAuthProperties; import java.util.List; import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.core.Authentication; @@ -21,7 +22,9 @@ public class OAuthLogoutSuccessHandler implements ServerLogoutSuccessHandler { public OAuthLogoutSuccessHandler(final OAuthProperties properties, final List logoutSuccessHandlers, - final @Qualifier("defaultOidcLogoutHandler") ServerLogoutSuccessHandler handler) { + @Autowired(required = false) + @Qualifier("defaultOidcLogoutHandler") + final ServerLogoutSuccessHandler handler) { this.properties = properties; this.logoutSuccessHandlers = logoutSuccessHandlers; this.defaultOidcLogoutHandler = handler; From 35fce232c2d4bee071383df75e10715011994965 Mon Sep 17 00:00:00 2001 From: Trey Rudolph Date: Fri, 29 May 2026 11:57:25 -0400 Subject: [PATCH 4/4] fix: provide no-op ReactiveClientRegistrationRepository for resource-server-only mode Spring Security's ReactiveOAuth2ClientConfiguration requires a ReactiveClientRegistrationRepository bean to exist even when no OAuth2 client login flow is configured. Returning null caused NoSuchBeanDefinitionException. Instead, provide a no-op lambda implementation that returns Mono.empty() for any registration lookup. Co-Authored-By: Claude --- .../io/kafbat/ui/config/auth/OAuthSecurityConfig.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index ed96f2f74..1756c3234 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -203,9 +203,9 @@ public ReactiveOAuth2UserService customOauth2User } @Bean - public InMemoryReactiveClientRegistrationRepository clientRegistrationRepository() { + public ReactiveClientRegistrationRepository clientRegistrationRepository() { if (!hasClientRegistrations()) { - return null; + return registrationId -> Mono.empty(); } final OAuth2ClientProperties props = OAuthPropertiesConverter.convertProperties(properties); final List registrations = @@ -215,11 +215,11 @@ public InMemoryReactiveClientRegistrationRepository clientRegistrationRepository @Bean public ServerLogoutSuccessHandler defaultOidcLogoutHandler( - Optional repository) { - if (repository.isEmpty()) { + ReactiveClientRegistrationRepository repository) { + if (!hasClientRegistrations()) { return null; } - return new OidcClientInitiatedServerLogoutSuccessHandler(repository.get()); + return new OidcClientInitiatedServerLogoutSuccessHandler(repository); } private boolean hasClientRegistrations() {