Skip to content

Commit

Permalink
feat(auths): allow roles and attributes directly from OIDC directly (t…
Browse files Browse the repository at this point in the history
  • Loading branch information
twobeeb authored Dec 4, 2021
1 parent fd7ba12 commit 3576c6c
Show file tree
Hide file tree
Showing 5 changed files with 371 additions and 10 deletions.
45 changes: 45 additions & 0 deletions docs/docs/configuration/authentifications/oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*$"
]
}
````
1 change: 1 addition & 0 deletions src/main/java/org/akhq/configs/Oidc.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public static class Provider {
private String defaultGroup;
private List<GroupMapping> groups = new ArrayList<>();
private List<UserMapping> users = new ArrayList<>();
private boolean useOidcClaim = false;
}

public Provider getProvider(String key) {
Expand Down
42 changes: 32 additions & 10 deletions src/main/java/org/akhq/modules/OidcUserDetailsMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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<String> 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<String> oidcGroups = getOidcGroups(provider, openIdClaims);

ClaimProvider.AKHQClaimRequest request = ClaimProvider.AKHQClaimRequest.builder()
.providerType(ClaimProvider.ProviderType.OIDC)
Expand All @@ -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<String> roles = (List<String>) openIdClaims.get(ROLES_KEY);
Map<String, Object> 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()));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> roles = userDetail.getRoles();

assertThat(roles, hasSize(1));
assertThat(roles, hasItem("topic/read"));

Map<String, Object> 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<String> roles = userDetail.getRoles();

assertThat(roles, hasSize(1));
assertThat(roles, hasItem("topic/read"));

Map<String, Object> 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));
}
}
Loading

0 comments on commit 3576c6c

Please sign in to comment.