From bf758809baac4c52d3bed3b88d078e97c38dfd24 Mon Sep 17 00:00:00 2001 From: stianst Date: Thu, 19 Jul 2018 21:41:47 +0200 Subject: [PATCH] KEYCLOAK-6229 OpenShift Token Review interface --- .../java/org/keycloak/common/Profile.java | 8 +- .../org/keycloak/models/KeycloakSession.java | 2 + .../ClientIdAndSecretAuthenticator.java | 4 + .../client/X509ClientAuthenticator.java | 4 + .../oidc/OIDCLoginProtocolService.java | 13 + .../protocol/oidc/ext/OIDCExtProvider.java | 14 + .../oidc/ext/OIDCExtProviderFactory.java | 29 ++ .../protocol/oidc/ext/OIDCExtSPI.java | 29 ++ .../OpenShiftTokenReviewEndpoint.java | 174 +++++++++ .../OpenShiftTokenReviewEndpointFactory.java | 46 +++ ...ShiftTokenReviewRequestRepresentation.java | 80 ++++ ...hiftTokenReviewResponseRepresentation.java | 154 ++++++++ .../services/DefaultKeycloakSession.java | 6 + .../services/ErrorResponseException.java | 21 +- ...k.protocol.oidc.ext.OIDCExtProviderFactory | 1 + .../services/org.keycloak.provider.Spi | 1 + .../forms/DummyClientAuthenticator.java | 117 ++++++ .../forms/PassThroughClientAuthenticator.java | 4 +- ....authentication.ClientAuthenticatorFactory | 3 +- .../keycloak/testsuite/util/OAuthClient.java | 14 +- .../admin/authentication/ProvidersTest.java | 3 +- .../OpenShiftTokenReviewEndpointTest.java | 354 ++++++++++++++++++ 22 files changed, 1073 insertions(+), 8 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProvider.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtSPI.java create mode 100644 services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java create mode 100644 services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpointFactory.java create mode 100755 services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewRequestRepresentation.java create mode 100755 services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewResponseRepresentation.java create mode 100644 services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory create mode 100755 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/DummyClientAuthenticator.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/openshift/OpenShiftTokenReviewEndpointTest.java diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 88d4f70698..19f57c10c0 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -33,7 +33,13 @@ import java.util.Set; public class Profile { public enum Feature { - AUTHORIZATION, IMPERSONATION, SCRIPTS, DOCKER, ACCOUNT2, TOKEN_EXCHANGE + ACCOUNT2, + AUTHORIZATION, + DOCKER, + IMPERSONATION, + OPENSHIFT_INTEGRATION, + SCRIPTS, + TOKEN_EXCHANGE } private enum ProductValue { diff --git a/server-spi/src/main/java/org/keycloak/models/KeycloakSession.java b/server-spi/src/main/java/org/keycloak/models/KeycloakSession.java index 72a0d4ca7c..1b90176b02 100755 --- a/server-spi/src/main/java/org/keycloak/models/KeycloakSession.java +++ b/server-spi/src/main/java/org/keycloak/models/KeycloakSession.java @@ -76,6 +76,8 @@ public interface KeycloakSession { Class getProviderClass(String providerClassName); Object getAttribute(String attribute); + T getAttribute(String attribute, Class clazz); + Object removeAttribute(String attribute); void setAttribute(String name, Object value); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java index 78f58da6d3..6e051df558 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java @@ -88,6 +88,10 @@ public class ClientIdAndSecretAuthenticator extends AbstractClientAuthenticator clientSecret = formData.getFirst(OAuth2Constants.CLIENT_SECRET); } + if (client_id == null) { + client_id = context.getSession().getAttribute("client_id", String.class); + } + if (client_id == null) { Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "Missing client_id parameter"); context.challenge(challengeResponse); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java index f6b7b91b00..324f806ae9 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java @@ -66,6 +66,10 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator { client_id = queryParams.getFirst(OAuth2Constants.CLIENT_ID); } + if (client_id == null) { + client_id = context.getSession().getAttribute("client_id", String.class); + } + if (client_id == null) { Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "Missing client_id parameter"); context.challenge(challengeResponse); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java index 8e2784f76b..3fe7268109 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java @@ -35,6 +35,7 @@ import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint; import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint; import org.keycloak.protocol.oidc.endpoints.TokenEndpoint; import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint; +import org.keycloak.protocol.oidc.ext.OIDCExtProvider; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.Cors; @@ -42,8 +43,10 @@ import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.util.CacheControlUtil; import javax.ws.rs.GET; +import javax.ws.rs.NotFoundException; import javax.ws.rs.OPTIONS; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; @@ -258,4 +261,14 @@ public class OIDCLoginProtocolService { } } + @Path("ext/{extension}") + public Object resolveExtension(@PathParam("extension") String extension) { + OIDCExtProvider provider = session.getProvider(OIDCExtProvider.class, extension); + if (provider != null) { + provider.setEvent(event); + return provider; + } + throw new NotFoundException(); + } + } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProvider.java new file mode 100644 index 0000000000..6ce631fb8e --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProvider.java @@ -0,0 +1,14 @@ +package org.keycloak.protocol.oidc.ext; + +import org.keycloak.events.EventBuilder; +import org.keycloak.provider.Provider; + +public interface OIDCExtProvider extends Provider { + + void setEvent(EventBuilder event); + + @Override + default void close() { + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProviderFactory.java new file mode 100644 index 0000000000..951a335006 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProviderFactory.java @@ -0,0 +1,29 @@ +package org.keycloak.protocol.oidc.ext; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderFactory; + +public interface OIDCExtProviderFactory extends ProviderFactory { + + @Override + default void init(Config.Scope config) { + + } + + @Override + default void postInit(KeycloakSessionFactory factory) { + + } + + @Override + default void close() { + + } + + @Override + default int order() { + return 0; + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtSPI.java b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtSPI.java new file mode 100644 index 0000000000..29821e5644 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtSPI.java @@ -0,0 +1,29 @@ +package org.keycloak.protocol.oidc.ext; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class OIDCExtSPI implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "openid-connect-ext"; + } + + @Override + public Class getProviderClass() { + return OIDCExtProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return OIDCExtProviderFactory.class; + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java new file mode 100644 index 0000000000..100207be30 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java @@ -0,0 +1,174 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.openshift; + +import org.keycloak.RSATokenVerifier; +import org.keycloak.common.VerificationException; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.oidc.ext.OIDCExtProvider; +import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; +import org.keycloak.representations.AccessToken; +import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.Urls; + +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.security.PublicKey; +import java.util.List; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class OpenShiftTokenReviewEndpoint implements OIDCExtProvider { + + private KeycloakSession session; + private TokenManager tokenManager; + private EventBuilder event; + + public OpenShiftTokenReviewEndpoint(KeycloakSession session) { + this.session = session; + this.tokenManager = new TokenManager(); + } + + @Override + public void setEvent(EventBuilder event) { + this.event = event; + } + + @Path("/") + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response tokenReview(OpenShiftTokenReviewRequestRepresentation reviewRequest) throws Exception { + return tokenReview(null, reviewRequest); + } + + @Path("/{client_id}") + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response tokenReview(@PathParam("client_id") String clientId, OpenShiftTokenReviewRequestRepresentation reviewRequest) throws Exception { + event.event(EventType.INTROSPECT_TOKEN); + + if (clientId != null) { + session.setAttribute("client_id", clientId); + } + + checkSsl(); + checkRealm(); + authorizeClient(); + + RealmModel realm = session.getContext().getRealm(); + + AccessToken token = null; + try { + RSATokenVerifier verifier = RSATokenVerifier.create(reviewRequest.getSpec().getToken()) + .realmUrl(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())); + + PublicKey publicKey = session.keys().getRsaPublicKey(realm, verifier.getHeader().getKeyId()); + if (publicKey == null) { + error(401, Errors.INVALID_TOKEN, "Invalid public key"); + } else { + verifier.publicKey(publicKey); + verifier.verify(); + token = verifier.getToken(); + } + } catch (VerificationException e) { + error(401, Errors.INVALID_TOKEN, "Token verification failure"); + } + + if (!tokenManager.isTokenValid(session, realm, token)) { + error(401, Errors.INVALID_TOKEN, "Token verification failure"); + } + + OpenShiftTokenReviewResponseRepresentation response = new OpenShiftTokenReviewResponseRepresentation(); + response.getStatus().setAuthenticated(true); + response.getStatus().setUser(new OpenShiftTokenReviewResponseRepresentation.User()); + + OpenShiftTokenReviewResponseRepresentation.User userRep = response.getStatus().getUser(); + userRep.setUid(token.getSubject()); + userRep.setUsername(token.getPreferredUsername()); + + if (token.getScope() != null && !token.getScope().isEmpty()) { + OpenShiftTokenReviewResponseRepresentation.Extra extra = new OpenShiftTokenReviewResponseRepresentation.Extra(); + extra.setScopes(token.getScope().split(" ")); + userRep.setExtra(extra); + } + + if (token.getOtherClaims() != null && token.getOtherClaims().get("groups") != null) { + List groups = (List) token.getOtherClaims().get("groups"); + userRep.setGroups(groups); + } + + event.success(); + return Response.ok(response, MediaType.APPLICATION_JSON).build(); + } + + private void checkSsl() { + if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && session.getContext().getRealm().getSslRequired().isRequired(session.getContext().getConnection())) { + error(401, Errors.SSL_REQUIRED, null); + } + } + + private void checkRealm() { + if (!session.getContext().getRealm().isEnabled()) { + error(401, Errors.REALM_DISABLED,null); + } + } + + private void authorizeClient() { + try { + ClientModel client = AuthorizeClientUtil.authorizeClient(session, event).getClient(); + event.client(client); + + if (client == null || client.isPublicClient()) { + error(401, Errors.INVALID_CLIENT, "Public client is not permitted to invoke token review endpoint"); + } + + } catch (ErrorResponseException ere) { + error(401, Errors.INVALID_CLIENT_CREDENTIALS, ere.getErrorDescription()); + } catch (Exception e) { + error(401, Errors.INVALID_CLIENT_CREDENTIALS, null); + } + } + + private void error(int statusCode, String error, String description) { + OpenShiftTokenReviewResponseRepresentation rep = new OpenShiftTokenReviewResponseRepresentation(); + rep.getStatus().setAuthenticated(false); + + Response response = Response.status(statusCode).entity(rep).type(MediaType.APPLICATION_JSON_TYPE).build(); + + event.error(error); + event.detail(Details.REASON, description); + + throw new ErrorResponseException(response); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpointFactory.java b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpointFactory.java new file mode 100644 index 0000000000..a58b8cedcd --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpointFactory.java @@ -0,0 +1,46 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.openshift; + +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.ext.OIDCExtProvider; +import org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class OpenShiftTokenReviewEndpointFactory implements OIDCExtProviderFactory, EnvironmentDependentProviderFactory { + + @Override + public OIDCExtProvider create(KeycloakSession session) { + return new OpenShiftTokenReviewEndpoint(session); + } + + @Override + public String getId() { + return "openshift-token-review"; + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.OPENSHIFT_INTEGRATION); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewRequestRepresentation.java b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewRequestRepresentation.java new file mode 100755 index 0000000000..cb62e0746b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewRequestRepresentation.java @@ -0,0 +1,80 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.openshift; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class OpenShiftTokenReviewRequestRepresentation implements Serializable { + + @JsonProperty("apiVersion") + private String apiVersion = "authentication.k8s.io/v1beta1"; + + @JsonProperty("kind") + private String kind = "TokenReview"; + + @JsonProperty("spec") + private Spec spec; + + public String getApiVersion() { + return apiVersion; + } + + public void setApiVersion(String apiVersion) { + this.apiVersion = apiVersion; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public Spec getSpec() { + return spec; + } + + public void setSpec(Spec spec) { + this.spec = spec; + } + + public static class Spec implements Serializable { + + @JsonProperty("token") + private String token; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewResponseRepresentation.java b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewResponseRepresentation.java new file mode 100755 index 0000000000..47918d2a32 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewResponseRepresentation.java @@ -0,0 +1,154 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.openshift; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class OpenShiftTokenReviewResponseRepresentation implements Serializable { + + @JsonProperty("apiVersion") + private String apiVersion = "authentication.k8s.io/v1beta1"; + + @JsonProperty("kind") + private String kind = "TokenReview"; + + @JsonProperty("status") + private Status status = new Status(); + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public String getApiVersion() { + return apiVersion; + } + + public void setApiVersion(String apiVersion) { + this.apiVersion = apiVersion; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public static class Status implements Serializable { + + @JsonProperty("authenticated") + private boolean authenticated; + + @JsonProperty("user") + protected User user; + + public boolean isAuthenticated() { + return authenticated; + } + + public void setAuthenticated(boolean authenticated) { + this.authenticated = authenticated; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + } + + public static class User implements Serializable { + + @JsonProperty("username") + protected String username; + + @JsonProperty("uid") + protected String uid; + + @JsonProperty("groups") + protected List groups = new LinkedList<>(); + + @JsonProperty("extra") + protected Extra extra; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getUid() { + return uid; + } + + public void setUid(String uid) { + this.uid = uid; + } + + public List getGroups() { + return groups; + } + + public void setGroups(List groups) { + this.groups = groups; + } + + public Extra getExtra() { + return extra; + } + + public void setExtra(Extra extra) { + this.extra = extra; + } + + } + + public static class Extra implements Serializable { + + @JsonProperty("scopes.authorization.openshift.io") + private String[] scopes; + + public String[] getScopes() { + return scopes; + } + + public void setScopes(String[] scopes) { + this.scopes = scopes; + } + } + +} diff --git a/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java b/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java index 6cabf76b7d..09c2378fc0 100644 --- a/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java +++ b/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java @@ -105,6 +105,12 @@ public class DefaultKeycloakSession implements KeycloakSession { return attributes.get(attribute); } + @Override + public T getAttribute(String attribute, Class clazz) { + Object value = getAttribute(attribute); + return value != null && clazz.isInstance(value) ? (T) value : null; + } + @Override public Object removeAttribute(String attribute) { return attributes.remove(attribute); diff --git a/services/src/main/java/org/keycloak/services/ErrorResponseException.java b/services/src/main/java/org/keycloak/services/ErrorResponseException.java index 07c3bf5c9d..e94bbafa65 100644 --- a/services/src/main/java/org/keycloak/services/ErrorResponseException.java +++ b/services/src/main/java/org/keycloak/services/ErrorResponseException.java @@ -28,20 +28,37 @@ import javax.ws.rs.core.Response; */ public class ErrorResponseException extends WebApplicationException { + private final Response response; private final String error; private final String errorDescription; private final Response.Status status; public ErrorResponseException(String error, String errorDescription, Response.Status status) { + this.response = null; this.error = error; this.errorDescription = errorDescription; this.status = status; } + public ErrorResponseException(Response response) { + this.response = response; + this.error = null; + this.errorDescription = null; + this.status = null; + } + + public String getErrorDescription() { + return errorDescription; + } + @Override public Response getResponse() { - OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription); - return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build(); + if (response != null) { + return response; + } else { + OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription); + return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build(); + } } } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory new file mode 100644 index 0000000000..3c884ef838 --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory @@ -0,0 +1 @@ +org.keycloak.protocol.openshift.OpenShiftTokenReviewEndpointFactory \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 7027379d7e..20e9747130 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -21,3 +21,4 @@ org.keycloak.services.clientregistration.ClientRegistrationSpi org.keycloak.services.clientregistration.policy.ClientRegistrationPolicySpi org.keycloak.authentication.actiontoken.ActionTokenHandlerSpi org.keycloak.services.x509.X509ClientCertificateLookupSpi +org.keycloak.protocol.oidc.ext.OIDCExtSPI \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/DummyClientAuthenticator.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/DummyClientAuthenticator.java new file mode 100755 index 0000000000..d4a6c6b9b1 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/DummyClientAuthenticator.java @@ -0,0 +1,117 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.forms; + +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.ClientAuthenticationFlowContext; +import org.keycloak.authentication.FlowStatus; +import org.keycloak.authentication.authenticators.client.AbstractClientAuthenticator; +import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @author Marek Posolda + */ +public class DummyClientAuthenticator extends AbstractClientAuthenticator { + + public static final String PROVIDER_ID = "testsuite-client-dummy"; + + public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.ALTERNATIVE + }; + + @Override + public void authenticateClient(ClientAuthenticationFlowContext context) { + ClientIdAndSecretAuthenticator authenticator = new ClientIdAndSecretAuthenticator(); + authenticator.authenticateClient(context); + if (context.getStatus().equals(FlowStatus.SUCCESS)) { + return; + } + + String clientId = context.getUriInfo().getQueryParameters().getFirst("client_id"); + + if (clientId == null) { + clientId = context.getSession().getAttribute("client_id", String.class); + } + + ClientModel client = context.getRealm().getClientByClientId(clientId); + if (client == null) { + context.failure(AuthenticationFlowError.CLIENT_NOT_FOUND, null); + return; + } + + context.getEvent().client(client); + context.setClient(client); + context.success(); + } + + @Override + public String getDisplayType() { + return "Testsuite ClientId Dummy"; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public String getHelpText() { + return "Dummy client authenticator, which authenticates the client with clientId only"; + } + + @Override + public List getConfigProperties() { + return new LinkedList<>(); + } + + @Override + public List getConfigPropertiesPerClient() { + return Collections.emptyList(); + } + + @Override + public Map getAdapterConfiguration(ClientModel client) { + return Collections.emptyMap(); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public Set getProtocolAuthenticatorMethods(String loginProtocol) { + return Collections.emptySet(); + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java index b5efe239ff..5088e7acd7 100755 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java @@ -41,7 +41,9 @@ public class PassThroughClientAuthenticator extends AbstractClientAuthenticator public static String clientId = "test-app"; public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { - AuthenticationExecutionModel.Requirement.REQUIRED + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.ALTERNATIVE, + AuthenticationExecutionModel.Requirement.DISABLED }; private static final List clientConfigProperties = new ArrayList(); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory index 05ea07c33d..67616c4619 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory @@ -15,4 +15,5 @@ # limitations under the License. # -org.keycloak.testsuite.forms.PassThroughClientAuthenticator \ No newline at end of file +org.keycloak.testsuite.forms.PassThroughClientAuthenticator +org.keycloak.testsuite.forms.DummyClientAuthenticator \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index a257096996..9b7579050f 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -148,6 +148,8 @@ public class OAuthClient { private String codeChallengeMethod; private String origin; + private boolean openid = true; + private Supplier httpClient = OAuthClient::newCloseableHttpClient; public class LogoutUrlBuilder { @@ -212,6 +214,7 @@ public class OAuthClient { codeChallenge = null; codeChallengeMethod = null; origin = null; + openid = true; } public void setDriver(WebDriver driver) { @@ -773,8 +776,10 @@ public class OAuthClient { b.queryParam(OIDCLoginProtocol.NONCE_PARAM, nonce); } - String scopeParam = TokenUtil.attachOIDCScope(scope); - b.queryParam(OAuth2Constants.SCOPE, scopeParam); + String scopeParam = openid ? TokenUtil.attachOIDCScope(scope) : scope; + if (scopeParam != null && !scopeParam.isEmpty()) { + b.queryParam(OAuth2Constants.SCOPE, scopeParam); + } if (maxAge != null) { b.queryParam(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge); @@ -883,6 +888,11 @@ public class OAuthClient { return this; } + public OAuthClient openid(boolean openid) { + this.openid = openid; + return this; + } + public OAuthClient uiLocales(String uiLocales){ this.uiLocales = uiLocales; return this; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java index 0aa53e045b..794199b278 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java @@ -81,12 +81,13 @@ public class ProvidersTest extends AbstractAuthenticationTest { "'client_secret' sent either in request parameters or in 'Authorization: Basic' header"); addProviderInfo(expected, "testsuite-client-passthrough", "Testsuite Dummy Client Validation", "Testsuite dummy authenticator, " + "which automatically authenticates hardcoded client (like 'test-app' )"); + addProviderInfo(expected, "testsuite-client-dummy", "Testsuite ClientId Dummy", + "Dummy client authenticator, which authenticates the client with clientId only"); addProviderInfo(expected, "client-x509", "X509 Certificate", "Validates client based on a X509 Certificate"); addProviderInfo(expected, "client-secret-jwt", "Signed Jwt with Client Secret", "Validates client based on signed JWT issued by client and signed with the Client Secret"); - compareProviders(expected, result); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/openshift/OpenShiftTokenReviewEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/openshift/OpenShiftTokenReviewEndpointTest.java new file mode 100644 index 0000000000..48006cac5b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/openshift/OpenShiftTokenReviewEndpointTest.java @@ -0,0 +1,354 @@ +package org.keycloak.testsuite.openshift; + +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.broker.provider.util.SimpleHttp; +import org.keycloak.common.util.Base64Url; +import org.keycloak.events.Details; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; +import org.keycloak.protocol.openshift.OpenShiftTokenReviewRequestRepresentation; +import org.keycloak.protocol.openshift.OpenShiftTokenReviewResponseRepresentation; +import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.arquillian.AuthServerTestEnricher; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.UserBuilder; + +import javax.ws.rs.core.Response; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.collection.IsIterableContainingInAnyOrder.containsInAnyOrder; +import static org.junit.Assert.*; + +public class OpenShiftTokenReviewEndpointTest extends AbstractTestRealmKeycloakTest { + + private static boolean flowConfigured; + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + ClientRepresentation client = testRealm.getClients().stream().filter(r -> r.getClientId().equals("test-app")).findFirst().get(); + + List mappers = new LinkedList<>(); + ProtocolMapperRepresentation mapper = new ProtocolMapperRepresentation(); + mapper.setName("groups"); + mapper.setProtocolMapper(GroupMembershipMapper.PROVIDER_ID); + mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Map config = new HashMap<>(); + config.put("full.path", "false"); + config.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "groups"); + config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + mapper.setConfig(config); + mappers.add(mapper); + + client.setProtocolMappers(mappers); + client.setPublicClient(false); + client.setClientAuthenticatorType("testsuite-client-dummy"); + + testRealm.getUsers().add(UserBuilder.create().username("groups-user").password("password").addGroups("/topGroup", "/topGroup/level2group").build()); + } + + @Before + public void enablePassthroughAuthenticator() { + if (!flowConfigured) { + HashMap data = new HashMap<>(); + data.put("newName", "testsuite-client-dummy"); + Response response = testRealm().flows().copy("clients", data); + assertEquals(201, response.getStatus()); + response.close(); + + data = new HashMap<>(); + data.put("provider", "testsuite-client-dummy"); + data.put("requirement", "ALTERNATIVE"); + + testRealm().flows().addExecution("testsuite-client-dummy", data); + + RealmRepresentation realmRep = testRealm().toRepresentation(); + realmRep.setClientAuthenticationFlow("testsuite-client-dummy"); + testRealm().update(realmRep); + + List executions = testRealm().flows().getExecutions("testsuite-client-dummy"); + for (AuthenticationExecutionInfoRepresentation e : executions) { + if (e.getProviderId().equals("testsuite-client-dummy")) { + e.setRequirement("ALTERNATIVE"); + testRealm().flows().updateExecutions("testsuite-client-dummy", e); + } + } + flowConfigured = true; + } + } + + @Test + public void basicTest() { + Review r = new Review().invoke() + .assertSuccess(); + + String userId = testRealm().users().search(r.username).get(0).getId(); + + OpenShiftTokenReviewResponseRepresentation.User user = r.response.getStatus().getUser(); + + assertEquals(userId, user.getUid()); + assertEquals("test-user@localhost", user.getUsername()); + assertNotNull(user.getExtra()); + + r.assertScope("openid", "email", "profile"); + } + + @Test + public void groups() { + new Review().username("groups-user") + .invoke() + .assertSuccess().assertGroups("topGroup", "level2group"); + } + + @Test + public void customScopes() { + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setProtocol("openid-connect"); + clientScope.setId("user:info"); + clientScope.setName("user:info"); + + testRealm().clientScopes().create(clientScope); + + ClientRepresentation clientRep = testRealm().clients().findByClientId("test-app").get(0); + + testRealm().clients().get(clientRep.getId()).addOptionalClientScope("user:info"); + + try { + oauth.scope("user:info"); + new Review() + .invoke() + .assertSuccess().assertScope("openid", "user:info", "profile", "email"); + } finally { + testRealm().clients().get(clientRep.getId()).removeOptionalClientScope("user:info"); + } + } + + @Test + public void emptyScope() { + ClientRepresentation clientRep = testRealm().clients().findByClientId("test-app").get(0); + + List scopes = new LinkedList<>(); + for (ClientScopeRepresentation s : testRealm().clients().get(clientRep.getId()).getDefaultClientScopes()) { + scopes.add(s.getId()); + } + + for (String s : scopes) { + testRealm().clients().get(clientRep.getId()).removeDefaultClientScope(s); + } + + oauth.openid(false); + try { + new Review() + .invoke() + .assertSuccess().assertEmptyScope(); + } finally { + oauth.openid(true); + + for (String s : scopes) { + testRealm().clients().get(clientRep.getId()).addDefaultClientScope(s); + } + } + } + + @Test + public void expiredToken() { + try { + new Review() + .runAfterTokenRequest(i -> setTimeOffset(testRealm().toRepresentation().getAccessTokenLifespan() + 10)) + .invoke() + .assertError(401, "Token verification failure"); + } finally { + resetTimeOffset(); + } + } + + @Test + public void invalidPublicKey() { + new Review() + .runAfterTokenRequest(i -> { + String header = i.token.split("\\.")[0]; + String s = new String(Base64Url.decode(header)); + s = s.replace(",\"kid\" : \"", ",\"kid\" : \"x"); + String newHeader = Base64Url.encode(s.getBytes()); + i.token = i.token.replaceFirst(header, newHeader); + }) + .invoke() + .assertError(401, "Invalid public key"); + } + + @Test + public void noUserSession() { + new Review() + .runAfterTokenRequest(i -> { + String userId = testRealm().users().search(i.username).get(0).getId(); + testRealm().users().get(userId).logout(); + }) + .invoke() + .assertError(401, "Token verification failure"); + } + + @Test + public void invalidTokenSignature() { + new Review() + .runAfterTokenRequest(i -> i.token += "x") + .invoke() + .assertError(401, "Token verification failure"); + } + + @Test + public void realmDisabled() { + RealmRepresentation r = testRealm().toRepresentation(); + try { + new Review().runAfterTokenRequest(i -> { + r.setEnabled(false); + testRealm().update(r); + }).invoke().assertError(401, null); + + + } finally { + r.setEnabled(true); + testRealm().update(r); + } + } + + @Test + public void publicClientNotPermitted() { + ClientRepresentation clientRep = testRealm().clients().findByClientId("test-app").get(0); + clientRep.setPublicClient(true); + testRealm().clients().get(clientRep.getId()).update(clientRep); + try { + new Review().invoke().assertError(401, "Public client is not permitted to invoke token review endpoint"); + } finally { + clientRep.setPublicClient(false); + testRealm().clients().get(clientRep.getId()).update(clientRep); + } + } + + private class Review { + + private String realm = "test"; + private String clientId = "test-app"; + private String username = "test-user@localhost"; + private String password = "password"; + private InvokeRunnable runAfterTokenRequest; + + private String token; + private int responseStatus; + private OpenShiftTokenReviewResponseRepresentation response; + + public Review username(String username) { + this.username = username; + return this; + } + + public Review runAfterTokenRequest(InvokeRunnable runnable) { + this.runAfterTokenRequest = runnable; + return this; + } + + public Review invoke() { + try { + String userId = testRealm().users().search(username).get(0).getId(); + oauth.doLogin(username, password); + EventRepresentation loginEvent = events.expectLogin().user(userId).assertEvent(); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(code, "password"); + + events.expectCodeToToken(loginEvent.getDetails().get(Details.CODE_ID), loginEvent.getSessionId()).detail("client_auth_method", "testsuite-client-dummy").user(userId).assertEvent(); + + token = accessTokenResponse.getAccessToken(); + + if (runAfterTokenRequest != null) { + runAfterTokenRequest.run(this); + } + + CloseableHttpClient client = HttpClientBuilder.create().build(); + + String url = AuthServerTestEnricher.getAuthServerContextRoot() + "/auth/realms/" + realm +"/protocol/openid-connect/ext/openshift-token-review/" + clientId; + + OpenShiftTokenReviewRequestRepresentation request = new OpenShiftTokenReviewRequestRepresentation(); + OpenShiftTokenReviewRequestRepresentation.Spec spec = new OpenShiftTokenReviewRequestRepresentation.Spec(); + spec.setToken(token); + request.setSpec(spec); + + SimpleHttp.Response r = SimpleHttp.doPost(url, client).json(request).asResponse(); + + responseStatus = r.getStatus(); + response = r.asJson(OpenShiftTokenReviewResponseRepresentation.class); + + assertEquals("authentication.k8s.io/v1beta1", response.getApiVersion()); + assertEquals("TokenReview", response.getKind()); + + client.close(); + + return this; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public Review assertSuccess() { + assertEquals(200, responseStatus); + assertTrue(response.getStatus().isAuthenticated()); + assertNotNull(response.getStatus().getUser()); + return this; + } + + private Review assertError(int expectedStatus, String expectedReason) { + assertEquals(expectedStatus, responseStatus); + assertFalse(response.getStatus().isAuthenticated()); + assertNull(response.getStatus().getUser()); + + if (expectedReason != null) { + EventRepresentation poll = events.poll(); + assertEquals(expectedReason, poll.getDetails().get(Details.REASON)); + } + + return this; + } + + private void assertScope(String... expectedScope) { + List actualScopes = Arrays.asList(response.getStatus().getUser().getExtra().getScopes()); + assertEquals(expectedScope.length, actualScopes.size()); + assertThat(actualScopes, containsInAnyOrder(expectedScope)); + } + + private void assertEmptyScope() { + assertNull(response.getStatus().getUser().getExtra()); + } + + private void assertGroups(String... expectedGroups) { + List actualGroups = new LinkedList<>(response.getStatus().getUser().getGroups()); + assertEquals(expectedGroups.length, actualGroups.size()); + assertThat(actualGroups, containsInAnyOrder(expectedGroups)); + } + + } + + private interface InvokeRunnable { + void run(Review i); + } + +}