From 3576c6cde3ad3dee4af962820243bae2af7a05e3 Mon Sep 17 00:00:00 2001 From: Julien Chanaud Date: Sat, 4 Dec 2021 21:33:54 +0100 Subject: [PATCH] feat(auths): allow roles and attributes directly from OIDC directly (#933) --- .../configuration/authentifications/oidc.md | 45 ++++ src/main/java/org/akhq/configs/Oidc.java | 1 + .../akhq/modules/OidcUserDetailsMapper.java | 42 +++- ...DirectClaimAuthenticationProviderTest.java | 212 ++++++++++++++++++ src/test/resources/application-keycloak.yml | 81 +++++++ 5 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 src/test/java/org/akhq/modules/OidcDirectClaimAuthenticationProviderTest.java create mode 100644 src/test/resources/application-keycloak.yml diff --git a/docs/docs/configuration/authentifications/oidc.md b/docs/docs/configuration/authentifications/oidc.md index 5f3683ee5..d91bc04a0 100644 --- a/docs/docs/configuration/authentifications/oidc.md +++ b/docs/docs/configuration/authentifications/oidc.md @@ -47,3 +47,48 @@ akhq: ``` The username field can be any string field, the roles field has to be a JSON array. + +## Direct OIDC mapping + +If you want to manage AKHQ roles an attributes directly with the OIDC provider, you can use the following configuration: +```yaml +akhq: + security: + oidc: + enabled: true + providers: + google: + label: "Login with Google" + username-field: preferred_username + use-oidc-claim: true +```` + +In this scenario, you need to make the OIDC provider return a JWT which have the following fields: +````json +{ + // Standard claims + "exp": 1635868816, + "iat": 1635868516, + "preferred_username": "json", + ... + "scope": "openid email profile", + // Mandatory AKHQ claims + "roles": [ + "acls/read", + "topic/data/delete", + "topic/data/insert", + "..." + ], + // Optional AKHQ claims + // If not set, no filtering is applied (full access ".*") + "topicsFilterRegexp": [ + "^json.*$" + ], + "connectsFilterRegexp": [ + "^json.*$" + ], + "consumerGroupsFilterRegexp": [ + "^json-consumer.*$" + ] +} +```` \ No newline at end of file diff --git a/src/main/java/org/akhq/configs/Oidc.java b/src/main/java/org/akhq/configs/Oidc.java index 2abe3e83a..6d86dcaa1 100644 --- a/src/main/java/org/akhq/configs/Oidc.java +++ b/src/main/java/org/akhq/configs/Oidc.java @@ -22,6 +22,7 @@ public static class Provider { private String defaultGroup; private List groups = new ArrayList<>(); private List users = new ArrayList<>(); + private boolean useOidcClaim = false; } public Provider getProvider(String key) { diff --git a/src/main/java/org/akhq/modules/OidcUserDetailsMapper.java b/src/main/java/org/akhq/modules/OidcUserDetailsMapper.java index 1130c2486..530bdab7a 100644 --- a/src/main/java/org/akhq/modules/OidcUserDetailsMapper.java +++ b/src/main/java/org/akhq/modules/OidcUserDetailsMapper.java @@ -19,10 +19,7 @@ import javax.inject.Inject; import javax.inject.Singleton; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Objects; +import java.util.*; import java.util.stream.Collectors; /** @@ -46,9 +43,19 @@ public OidcUserDetailsMapper(OpenIdAdditionalClaimsConfiguration openIdAdditiona @NonNull @Override public AuthenticationResponse createAuthenticationResponse(String providerName, OpenIdTokenResponse tokenResponse, OpenIdClaims openIdClaims, @Nullable State state) { + // get the current OIDC provider + Oidc.Provider provider = oidc.getProvider(providerName); + // get username and groups declared from OIDC system - String oidcUsername = getUsername(providerName, tokenResponse, openIdClaims); - List oidcGroups = getOidcGroups(oidc.getProvider(providerName), openIdClaims); + String oidcUsername = getUsername(provider, openIdClaims); + + // Some OIDC providers like Keycloak can return a claim with roles and attributes directly, + // so we don't use the AKHQ internal ClaimProvider mechanism + if(provider.isUseOidcClaim()){ + return createDirectClaimAuthenticationResponse(oidcUsername, openIdClaims); + } + + List oidcGroups = getOidcGroups(provider, openIdClaims); ClaimProvider.AKHQClaimRequest request = ClaimProvider.AKHQClaimRequest.builder() .providerType(ClaimProvider.ProviderType.OIDC) @@ -66,16 +73,31 @@ public AuthenticationResponse createAuthenticationResponse(String providerName, } } + private AuthenticationResponse createDirectClaimAuthenticationResponse(String oidcUsername, OpenIdClaims openIdClaims) { + String ROLES_KEY = "roles"; + if(openIdClaims.contains(ROLES_KEY) && openIdClaims.get(ROLES_KEY) instanceof List){ + List roles = (List) openIdClaims.get(ROLES_KEY); + Map attributes = openIdClaims.getClaims() + .entrySet() + .stream() + // keep only topicsFilterRegexp, connectsFilterRegexp, consumerGroupsFilterRegexp and potential future filters + .filter(kv -> kv.getKey().matches(".*FilterRegexp$")) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + return new UserDetails(oidcUsername, roles, attributes); + } + + return new AuthenticationFailed("Exception during Authentication: use-oidc-claim config requires attribute " + + ROLES_KEY + " in the OIDC claim"); + } + /** * Tries to read the username from the configured username field. * - * @param providerName The OpenID provider name - * @param tokenResponse The token response + * @param provider The OpenID provider * @param openIdClaims The OpenID claims * @return The username to set in the {@link UserDetails} */ - protected String getUsername(String providerName, OpenIdTokenResponse tokenResponse, OpenIdClaims openIdClaims) { - Oidc.Provider provider = oidc.getProvider(providerName); + protected String getUsername(Oidc.Provider provider, OpenIdClaims openIdClaims) { return Objects.toString(openIdClaims.get(provider.getUsernameField())); } diff --git a/src/test/java/org/akhq/modules/OidcDirectClaimAuthenticationProviderTest.java b/src/test/java/org/akhq/modules/OidcDirectClaimAuthenticationProviderTest.java new file mode 100644 index 000000000..565daa691 --- /dev/null +++ b/src/test/java/org/akhq/modules/OidcDirectClaimAuthenticationProviderTest.java @@ -0,0 +1,212 @@ +package org.akhq.modules; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; +import io.micronaut.core.async.publisher.Publishers; +import io.micronaut.security.authentication.*; +import io.micronaut.security.oauth2.client.DefaultOpenIdProviderMetadata; +import io.micronaut.security.oauth2.endpoint.token.request.TokenEndpointClient; +import io.micronaut.security.oauth2.endpoint.token.response.OpenIdClaims; +import io.micronaut.security.oauth2.endpoint.token.response.OpenIdTokenResponse; +import io.micronaut.security.oauth2.endpoint.token.response.validation.OpenIdTokenResponseValidator; +import io.micronaut.test.annotation.MockBean; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.reactivex.Flowable; +import org.akhq.controllers.AkhqController; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +import javax.inject.Inject; +import javax.inject.Named; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +@MicronautTest(environments = "keycloak") +class OidcDirectClaimAuthenticationProviderTest { + + @Named("oidc") + @Inject + AuthenticationProvider oidcProvider; + + @Inject + TokenEndpointClient tokenEndpointClient; + + @Inject + OpenIdTokenResponseValidator openIdTokenResponseValidator; + + @Inject + DefaultOpenIdProviderMetadata defaultOpenIdProviderMetadata; + + @Inject + AkhqController akhqController; + + @Named("oidc") + @MockBean(TokenEndpointClient.class) + TokenEndpointClient tokenEndpointClient() { + return mock(TokenEndpointClient.class); + } + + @Named("oidc") + @MockBean(OpenIdTokenResponseValidator.class) + OpenIdTokenResponseValidator openIdTokenResponseValidator() { + return mock(OpenIdTokenResponseValidator.class); + } + + @Named("oidc") + @MockBean(DefaultOpenIdProviderMetadata.class) + DefaultOpenIdProviderMetadata defaultOpenIdProviderMetadata() { + return mock(DefaultOpenIdProviderMetadata.class); + } + + @Test + void successSingleOidcGroup() { + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .claim(OpenIdClaims.CLAIMS_PREFERRED_USERNAME, "user") + .claim("roles", List.of("topic/read")) + .claim("topicsFilterRegexp", List.of("^topic1$", "^topic2$")) + .claim("connectsFilterRegexp", List.of("^connect1", "^connect2")) + .claim("consumerGroupsFilterRegexp", List.of("^cg1", "^cg2")) + .build(); + JWT jwt = new PlainJWT(claimsSet); + + Mockito.when(tokenEndpointClient.sendRequest(ArgumentMatchers.any())) + .thenReturn(Publishers.just(new OpenIdTokenResponse())); + Mockito.when(openIdTokenResponseValidator.validate(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(Optional.of(jwt)); + + AuthenticationResponse response = Flowable + .fromPublisher(oidcProvider.authenticate(null, new UsernamePasswordCredentials( + "user", + "pass" + ))).blockingFirst(); + + assertThat(response, instanceOf(UserDetails.class)); + + UserDetails userDetail = (UserDetails) response; + + assertTrue(userDetail.isAuthenticated()); + assertEquals("user", userDetail.getUsername()); + + Collection roles = userDetail.getRoles(); + + assertThat(roles, hasSize(1)); + assertThat(roles, hasItem("topic/read")); + + Map attributes = userDetail.getAttributes("roles", "username"); + assertThat(attributes.keySet(), hasItem("topicsFilterRegexp")); + assertThat(attributes.keySet(), hasItem("connectsFilterRegexp")); + assertThat(attributes.keySet(), hasItem("consumerGroupsFilterRegexp")); + + assertEquals("^topic1$", ((List) attributes.get("topicsFilterRegexp")).get(0)); + + } + + @Test + void successSingleOidcGroup_KeepsAllFilterRegexp() { + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .claim(OpenIdClaims.CLAIMS_PREFERRED_USERNAME, "user") + .claim("roles", List.of("topic/read")) + .claim("topicsFilterRegexp", List.of("^topic1$", "^topic2$")) + .claim("connectsFilterRegexp", List.of("^connect1", "^connect2")) + .claim("consumerGroupsFilterRegexp", List.of("^cg1", "^cg2")) + .claim("futureFilterRegexp", List.of("^future1")) + .claim("donotkeep", "drop") + .claim("remove-me", "drop") + .claim("FilterRegexpRemove", "drop") + .claim("aaaFilterRegexpaaa", "drop") + .build(); + JWT jwt = new PlainJWT(claimsSet); + + Mockito.when(tokenEndpointClient.sendRequest(ArgumentMatchers.any())) + .thenReturn(Publishers.just(new OpenIdTokenResponse())); + Mockito.when(openIdTokenResponseValidator.validate(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(Optional.of(jwt)); + + AuthenticationResponse response = Flowable + .fromPublisher(oidcProvider.authenticate(null, new UsernamePasswordCredentials( + "user", + "pass" + ))).blockingFirst(); + + assertThat(response, instanceOf(UserDetails.class)); + + UserDetails userDetail = (UserDetails) response; + + assertTrue(userDetail.isAuthenticated()); + assertEquals("user", userDetail.getUsername()); + + Collection roles = userDetail.getRoles(); + + assertThat(roles, hasSize(1)); + assertThat(roles, hasItem("topic/read")); + + Map attributes = userDetail.getAttributes("roles", "username"); + + assertEquals(6, attributes.size()); + assertThat(attributes.keySet(), hasItem("username")); + assertThat(attributes.keySet(), hasItem("roles")); + assertThat(attributes.keySet(), hasItem("topicsFilterRegexp")); + assertThat(attributes.keySet(), hasItem("connectsFilterRegexp")); + assertThat(attributes.keySet(), hasItem("consumerGroupsFilterRegexp")); + assertThat(attributes.keySet(), hasItem("futureFilterRegexp")); + } + + @Test + void failureNoRoles() { + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .claim(OpenIdClaims.CLAIMS_PREFERRED_USERNAME, "user") + //.claim("roles", List.of("topic/read")) no roles + .claim("topicsFilterRegexp", List.of("^topic1$", "^topic2$")) + .claim("connectsFilterRegexp", List.of("^connect1", "^connect2")) + .claim("consumerGroupsFilterRegexp", List.of("^cg1", "^cg2")) + .build(); + JWT jwt = new PlainJWT(claimsSet); + + Mockito.when(tokenEndpointClient.sendRequest(ArgumentMatchers.any())) + .thenReturn(Publishers.just(new OpenIdTokenResponse())); + Mockito.when(openIdTokenResponseValidator.validate(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(Optional.of(jwt)); + + AuthenticationResponse response = Flowable + .fromPublisher(oidcProvider.authenticate(null, new UsernamePasswordCredentials( + "user", + "pass" + ))).blockingFirst(); + + assertThat(response, instanceOf(AuthenticationFailed.class)); + } + + @Test + void failureRolesNotAList() { + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .claim(OpenIdClaims.CLAIMS_PREFERRED_USERNAME, "user") + .claim("roles", "string") //not a list of roles + .claim("topicsFilterRegexp", List.of("^topic1$", "^topic2$")) + .claim("connectsFilterRegexp", List.of("^connect1", "^connect2")) + .claim("consumerGroupsFilterRegexp", List.of("^cg1", "^cg2")) + .build(); + JWT jwt = new PlainJWT(claimsSet); + + Mockito.when(tokenEndpointClient.sendRequest(ArgumentMatchers.any())) + .thenReturn(Publishers.just(new OpenIdTokenResponse())); + Mockito.when(openIdTokenResponseValidator.validate(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(Optional.of(jwt)); + + AuthenticationResponse response = Flowable + .fromPublisher(oidcProvider.authenticate(null, new UsernamePasswordCredentials( + "user", + "pass" + ))).blockingFirst(); + + assertThat(response, instanceOf(AuthenticationFailed.class)); + } +} diff --git a/src/test/resources/application-keycloak.yml b/src/test/resources/application-keycloak.yml new file mode 100644 index 000000000..b3e1e60c7 --- /dev/null +++ b/src/test/resources/application-keycloak.yml @@ -0,0 +1,81 @@ +micronaut: + application: + name: akhq + + security: + enabled: true + endpoints: + login: + path: "/login" + logout: + path: "/logout" + get-allowed: true + token: + jwt: + enabled: true + cookie: + enabled: true + signatures: + secret: + generator: + secret: d93YX6S7bukwTrmDLakBBWA3taHUkL4qkBqX2NYRJv5UQAjwCU4Kuey3mTTSgXAL + ldap: + enabled: false + oauth2: + enabled: true + clients: + oidc: + grant-type: password + openid: + issuer: "http://no.url" + token: "fake-token" + +akhq: + server: + access-log: + enabled: false + + clients-defaults: + consumer: + properties: + group.id: Akhq + enable.auto.commit: "false" + + topic: + replication: 1 + retention: 86400000 + partition: 1 + internal-regexps: + - "^_.*$" + - "^.*_schemas$" + - "^.*connect-config$" + - "^.*connect-offsets$1" + - "^.*connect-status$" + stream-regexps: + - "^.*-changelog$" + - "^.*-repartition$" + - "^.*-rekey$" + + topic-data: + poll-timeout: 5000 + + pagination: + page-size: 5 + + security: + default-group: no-filter + basic-auth: [] + groups: + no-filter: + name: no-filter + roles: + - topic/read + - topic/insert + - topic/delete + - registry/version/delete + oidc: + enabled: true + providers: + oidc: + username-field: preferred_username + use-oidc-claim: true