KEYCLOAK-6229 OpenShift Token Review interface

This commit is contained in:
stianst 2018-07-19 21:41:47 +02:00 committed by Stian Thorgersen
parent 1fb4ca4525
commit bf758809ba
22 changed files with 1073 additions and 8 deletions

View file

@ -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 {

View file

@ -76,6 +76,8 @@ public interface KeycloakSession {
Class<? extends Provider> getProviderClass(String providerClassName);
Object getAttribute(String attribute);
<T> T getAttribute(String attribute, Class<T> clazz);
Object removeAttribute(String attribute);
void setAttribute(String name, Object value);

View file

@ -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);

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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() {
}
}

View file

@ -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<OIDCExtProvider> {
@Override
default void init(Config.Scope config) {
}
@Override
default void postInit(KeycloakSessionFactory factory) {
}
@Override
default void close() {
}
@Override
default int order() {
return 0;
}
}

View file

@ -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<? extends Provider> getProviderClass() {
return OIDCExtProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return OIDCExtProviderFactory.class;
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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<String> groups = (List<String>) 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);
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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);
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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;
}
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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<String> 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<String> getGroups() {
return groups;
}
public void setGroups(List<String> 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;
}
}
}

View file

@ -105,6 +105,12 @@ public class DefaultKeycloakSession implements KeycloakSession {
return attributes.get(attribute);
}
@Override
public <T> T getAttribute(String attribute, Class<T> clazz) {
Object value = getAttribute(attribute);
return value != null && clazz.isInstance(value) ? (T) value : null;
}
@Override
public Object removeAttribute(String attribute) {
return attributes.remove(attribute);

View file

@ -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() {
if (response != null) {
return response;
} else {
OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription);
return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build();
}
}
}

View file

@ -0,0 +1 @@
org.keycloak.protocol.openshift.OpenShiftTokenReviewEndpointFactory

View file

@ -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

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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<ProviderConfigProperty> getConfigProperties() {
return new LinkedList<>();
}
@Override
public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
return Collections.emptyList();
}
@Override
public Map<String, Object> getAdapterConfiguration(ClientModel client) {
return Collections.emptyMap();
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public Set<String> getProtocolAuthenticatorMethods(String loginProtocol) {
return Collections.emptySet();
}
}

View file

@ -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<ProviderConfigProperty> clientConfigProperties = new ArrayList<ProviderConfigProperty>();

View file

@ -16,3 +16,4 @@
#
org.keycloak.testsuite.forms.PassThroughClientAuthenticator
org.keycloak.testsuite.forms.DummyClientAuthenticator

View file

@ -148,6 +148,8 @@ public class OAuthClient {
private String codeChallengeMethod;
private String origin;
private boolean openid = true;
private Supplier<CloseableHttpClient> 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);
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;

View file

@ -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);
}

View file

@ -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<ProtocolMapperRepresentation> mappers = new LinkedList<>();
ProtocolMapperRepresentation mapper = new ProtocolMapperRepresentation();
mapper.setName("groups");
mapper.setProtocolMapper(GroupMembershipMapper.PROVIDER_ID);
mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Map<String, String> 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<String, String> 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<AuthenticationExecutionInfoRepresentation> 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<String> 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<String> 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<String> actualGroups = new LinkedList<>(response.getStatus().getUser().getGroups());
assertEquals(expectedGroups.length, actualGroups.size());
assertThat(actualGroups, containsInAnyOrder(expectedGroups));
}
}
private interface InvokeRunnable {
void run(Review i);
}
}