diff --git a/client-api/pom.xml b/client-api/pom.xml new file mode 100755 index 0000000000..e386849f08 --- /dev/null +++ b/client-api/pom.xml @@ -0,0 +1,31 @@ + + + + keycloak-parent + org.keycloak + 1.6.0.Final-SNAPSHOT + + 4.0.0 + + keycloak-client-api + Keycloak Client API + + + + + org.keycloak + keycloak-core + + + org.apache.httpcomponents + httpclient + + + junit + junit + test + + + + diff --git a/client-api/src/main/java/org/keycloak/client/registration/ClientRegistration.java b/client-api/src/main/java/org/keycloak/client/registration/ClientRegistration.java new file mode 100644 index 0000000000..dae44a4157 --- /dev/null +++ b/client-api/src/main/java/org/keycloak/client/registration/ClientRegistration.java @@ -0,0 +1,275 @@ +package org.keycloak.client.registration; + +import org.apache.http.HttpHeaders; +import org.apache.http.HttpRequest; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.util.Base64; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.io.InputStream; + +/** + * @author Stian Thorgersen + */ +public class ClientRegistration { + + private String clientRegistrationUrl; + private HttpClient httpClient; + private Auth auth; + + public static ClientRegistrationBuilder create() { + return new ClientRegistrationBuilder(); + } + + private ClientRegistration() { + } + + public ClientRepresentation create(ClientRepresentation client) throws ClientRegistrationException { + String content = serialize(client); + InputStream resultStream = doPost(content); + return deserialize(resultStream, ClientRepresentation.class); + } + + public ClientRepresentation get() throws ClientRegistrationException { + if (auth instanceof ClientIdSecretAuth) { + String clientId = ((ClientIdSecretAuth) auth).clientId; + return get(clientId); + } else { + throw new ClientRegistrationException("Requires client authentication"); + } + } + + public ClientRepresentation get(String clientId) throws ClientRegistrationException { + InputStream resultStream = doGet(clientId); + return resultStream != null ? deserialize(resultStream, ClientRepresentation.class) : null; + } + + public void update(ClientRepresentation client) throws ClientRegistrationException { + String content = serialize(client); + doPut(content, client.getClientId()); + } + + public void delete() throws ClientRegistrationException { + if (auth instanceof ClientIdSecretAuth) { + String clientId = ((ClientIdSecretAuth) auth).clientId; + delete(clientId); + } else { + throw new ClientRegistrationException("Requires client authentication"); + } + } + + public void delete(String clientId) throws ClientRegistrationException { + doDelete(clientId); + } + + public void close() throws ClientRegistrationException { + if (httpClient instanceof CloseableHttpClient) { + try { + ((CloseableHttpClient) httpClient).close(); + } catch (IOException e) { + throw new ClientRegistrationException("Failed to close http client", e); + } + } + } + + private InputStream doPost(String content) throws ClientRegistrationException { + try { + HttpPost request = new HttpPost(clientRegistrationUrl); + + request.setHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + request.setHeader(HttpHeaders.ACCEPT, "application/json"); + request.setEntity(new StringEntity(content)); + + auth.addAuth(request); + + HttpResponse response = httpClient.execute(request); + InputStream responseStream = null; + if (response.getEntity() != null) { + responseStream = response.getEntity().getContent(); + } + + if (response.getStatusLine().getStatusCode() == 201) { + return responseStream; + } else { + responseStream.close(); + throw new HttpErrorException(response.getStatusLine()); + } + } catch (IOException e) { + throw new ClientRegistrationException("Failed to send request", e); + } + } + + private InputStream doGet(String endpoint) throws ClientRegistrationException { + try { + HttpGet request = new HttpGet(clientRegistrationUrl + "/" + endpoint); + + request.setHeader(HttpHeaders.ACCEPT, "application/json"); + + auth.addAuth(request); + + HttpResponse response = httpClient.execute(request); + InputStream responseStream = null; + if (response.getEntity() != null) { + responseStream = response.getEntity().getContent(); + } + + if (response.getStatusLine().getStatusCode() == 200) { + return responseStream; + } else if (response.getStatusLine().getStatusCode() == 404) { + responseStream.close(); + return null; + } else { + responseStream.close(); + throw new HttpErrorException(response.getStatusLine()); + } + } catch (IOException e) { + throw new ClientRegistrationException("Failed to send request", e); + } + } + + private void doPut(String content, String endpoint) throws ClientRegistrationException { + try { + HttpPut request = new HttpPut(clientRegistrationUrl + "/" + endpoint); + + request.setHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + request.setHeader(HttpHeaders.ACCEPT, "application/json"); + request.setEntity(new StringEntity(content)); + + auth.addAuth(request); + + HttpResponse response = httpClient.execute(request); + if (response.getEntity() != null) { + response.getEntity().getContent().close(); + } + + if (response.getStatusLine().getStatusCode() != 200) { + throw new HttpErrorException(response.getStatusLine()); + } + } catch (IOException e) { + throw new ClientRegistrationException("Failed to send request", e); + } + } + + private void doDelete(String endpoint) throws ClientRegistrationException { + try { + HttpDelete request = new HttpDelete(clientRegistrationUrl + "/" + endpoint); + + auth.addAuth(request); + + HttpResponse response = httpClient.execute(request); + if (response.getEntity() != null) { + response.getEntity().getContent().close(); + } + + if (response.getStatusLine().getStatusCode() != 200) { + throw new HttpErrorException(response.getStatusLine()); + } + } catch (IOException e) { + throw new ClientRegistrationException("Failed to send request", e); + } + } + + private String serialize(ClientRepresentation client) throws ClientRegistrationException { + try { + return JsonSerialization.writeValueAsString(client); + } catch (IOException e) { + throw new ClientRegistrationException("Failed to write json object", e); + } + } + + private T deserialize(InputStream inputStream, Class clazz) throws ClientRegistrationException { + try { + return JsonSerialization.readValue(inputStream, clazz); + } catch (IOException e) { + throw new ClientRegistrationException("Failed to read json object", e); + } + } + + public static class ClientRegistrationBuilder { + + private String realm; + + private String authServerUrl; + + private Auth auth; + + private HttpClient httpClient; + + public ClientRegistrationBuilder realm(String realm) { + this.realm = realm; + return this; + } + public ClientRegistrationBuilder authServerUrl(String authServerUrl) { + this.authServerUrl = authServerUrl; + return this; + } + + public ClientRegistrationBuilder auth(String token) { + this.auth = new TokenAuth(token); + return this; + } + + public ClientRegistrationBuilder auth(String clientId, String clientSecret) { + this.auth = new ClientIdSecretAuth(clientId, clientSecret); + return this; + } + + public ClientRegistrationBuilder httpClient(HttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + public ClientRegistration build() { + ClientRegistration clientRegistration = new ClientRegistration(); + clientRegistration.clientRegistrationUrl = authServerUrl + "/realms/" + realm + "/client-registration"; + + clientRegistration.httpClient = httpClient != null ? httpClient : HttpClients.createDefault(); + clientRegistration.auth = auth; + + return clientRegistration; + } + + } + + public interface Auth { + void addAuth(HttpRequest httpRequest); + } + + public static class AuthorizationHeaderAuth implements Auth { + private String credentials; + + public AuthorizationHeaderAuth(String credentials) { + this.credentials = credentials; + } + + public void addAuth(HttpRequest httpRequest) { + httpRequest.setHeader(HttpHeaders.AUTHORIZATION, credentials); + } + } + + public static class TokenAuth extends AuthorizationHeaderAuth { + public TokenAuth(String token) { + super("Bearer " + token); + } + } + + public static class ClientIdSecretAuth extends AuthorizationHeaderAuth { + private String clientId; + + public ClientIdSecretAuth(String clientId, String clientSecret) { + super("Basic " + Base64.encodeBytes((clientId + ":" + clientSecret).getBytes())); + this.clientId = clientId; + } + } + +} diff --git a/client-api/src/main/java/org/keycloak/client/registration/ClientRegistrationException.java b/client-api/src/main/java/org/keycloak/client/registration/ClientRegistrationException.java new file mode 100644 index 0000000000..43f743c05b --- /dev/null +++ b/client-api/src/main/java/org/keycloak/client/registration/ClientRegistrationException.java @@ -0,0 +1,16 @@ +package org.keycloak.client.registration; + +/** + * @author Stian Thorgersen + */ +public class ClientRegistrationException extends Exception { + + public ClientRegistrationException(String s, Throwable throwable) { + super(s, throwable); + } + + public ClientRegistrationException(String s) { + super(s); + } + +} diff --git a/client-api/src/main/java/org/keycloak/client/registration/HttpErrorException.java b/client-api/src/main/java/org/keycloak/client/registration/HttpErrorException.java new file mode 100644 index 0000000000..b25e0330dc --- /dev/null +++ b/client-api/src/main/java/org/keycloak/client/registration/HttpErrorException.java @@ -0,0 +1,22 @@ +package org.keycloak.client.registration; + +import org.apache.http.StatusLine; + +import java.io.IOException; + +/** + * @author Stian Thorgersen + */ +public class HttpErrorException extends IOException { + + private StatusLine statusLine; + + public HttpErrorException(StatusLine statusLine) { + this.statusLine = statusLine; + } + + public StatusLine getStatusLine() { + return statusLine; + } + +} diff --git a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java index 99f8f3ce3e..ee96b0471f 100755 --- a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java @@ -1,14 +1,12 @@ package org.keycloak.representations.idm; -import java.util.ArrayList; +import org.codehaus.jackson.annotate.JsonIgnore; + import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; -import org.codehaus.jackson.annotate.JsonIgnore; -import org.keycloak.util.MultivaluedHashMap; - /** * @author Bill Burke * @version $Revision: 1 $ diff --git a/events/api/src/main/java/org/keycloak/events/EventType.java b/events/api/src/main/java/org/keycloak/events/EventType.java index eacce62a87..ac93a4f7a9 100755 --- a/events/api/src/main/java/org/keycloak/events/EventType.java +++ b/events/api/src/main/java/org/keycloak/events/EventType.java @@ -69,7 +69,16 @@ public enum EventType { IMPERSONATE(true), CUSTOM_REQUIRED_ACTION(true), CUSTOM_REQUIRED_ACTION_ERROR(true), - EXECUTE_ACTIONS(true); + EXECUTE_ACTIONS(true), + + CLIENT_INFO(false), + CLIENT_INFO_ERROR(false), + CLIENT_REGISTER(true), + CLIENT_REGISTER_ERROR(true), + CLIENT_UPDATE(true), + CLIENT_UPDATE_ERROR(true), + CLIENT_DELETE(true), + CLIENT_DELETE_ERROR(true); private boolean saveByDefault; diff --git a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties index 94b06272f2..65ad002ae2 100644 --- a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -89,6 +89,7 @@ personalInfo=Personal Info: role_admin=Admin role_realm-admin=Realm Admin role_create-realm=Create realm +role_create-client=Create client role_view-realm=View realm role_view-users=View users role_view-applications=View applications diff --git a/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_6_0.java b/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_6_0.java index d668ae0444..ca47f3e906 100644 --- a/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_6_0.java +++ b/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_6_0.java @@ -70,6 +70,15 @@ public class MigrateTo1_6_0 { if ((adminConsoleClient != null) && !localeMapperAdded(adminConsoleClient)) { adminConsoleClient.addProtocolMapper(localeMapper); } + + ClientModel client = realm.getMasterAdminClient(); + if (client.getRole(AdminRoles.CREATE_CLIENT) == null) { + RoleModel role = client.addRole(AdminRoles.CREATE_CLIENT); + role.setDescription("${role_" + AdminRoles.CREATE_CLIENT + "}"); + role.setScopeParamRequired(false); + + realm.getRole(AdminRoles.ADMIN).addCompositeRole(role); + } } } diff --git a/model/api/src/main/java/org/keycloak/models/AdminRoles.java b/model/api/src/main/java/org/keycloak/models/AdminRoles.java index c067a1d842..2aa91dff9f 100755 --- a/model/api/src/main/java/org/keycloak/models/AdminRoles.java +++ b/model/api/src/main/java/org/keycloak/models/AdminRoles.java @@ -13,6 +13,7 @@ public class AdminRoles { public static String REALM_ADMIN = "realm-admin"; public static String CREATE_REALM = "create-realm"; + public static String CREATE_CLIENT = "create-client"; public static String VIEW_REALM = "view-realm"; public static String VIEW_USERS = "view-users"; @@ -26,6 +27,6 @@ public class AdminRoles { public static String MANAGE_CLIENTS = "manage-clients"; public static String MANAGE_EVENTS = "manage-events"; - public static String[] ALL_REALM_ROLES = {VIEW_REALM, VIEW_USERS, VIEW_CLIENTS, VIEW_EVENTS, VIEW_IDENTITY_PROVIDERS, MANAGE_REALM, MANAGE_USERS, MANAGE_CLIENTS, MANAGE_EVENTS, MANAGE_IDENTITY_PROVIDERS}; + public static String[] ALL_REALM_ROLES = {CREATE_CLIENT, VIEW_REALM, VIEW_USERS, VIEW_CLIENTS, VIEW_EVENTS, VIEW_IDENTITY_PROVIDERS, MANAGE_REALM, MANAGE_USERS, MANAGE_CLIENTS, MANAGE_EVENTS, MANAGE_IDENTITY_PROVIDERS}; } diff --git a/pom.xml b/pom.xml index 1ad5e1a1c1..224a32346d 100755 --- a/pom.xml +++ b/pom.xml @@ -137,6 +137,7 @@ common core core-jaxrs + client-api connections dependencies events @@ -650,6 +651,11 @@ keycloak-core ${project.version} + + org.keycloak + keycloak-client-api + ${project.version} + org.keycloak keycloak-core-jaxrs 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 bb17291d85..e476280020 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 @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; @@ -44,7 +45,11 @@ public class ClientIdAndSecretAuthenticator extends AbstractClientAuthenticator String clientSecret = null; String authorizationHeader = context.getHttpRequest().getHttpHeaders().getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION); - MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + + MediaType mediaType = context.getHttpRequest().getHttpHeaders().getMediaType(); + boolean hasFormData = mediaType != null && mediaType.isCompatible(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + + MultivaluedMap formData = hasFormData ? context.getHttpRequest().getDecodedFormParameters() : null; if (authorizationHeader != null) { String[] usernameSecret = BasicAuthHelper.parseHeader(authorizationHeader); @@ -54,7 +59,7 @@ public class ClientIdAndSecretAuthenticator extends AbstractClientAuthenticator } else { // Don't send 401 if client_id parameter was sent in request. For example IE may automatically send "Authorization: Negotiate" in XHR requests even for public clients - if (!formData.containsKey(OAuth2Constants.CLIENT_ID)) { + if (formData != null && !formData.containsKey(OAuth2Constants.CLIENT_ID)) { Response challengeResponse = Response.status(Response.Status.UNAUTHORIZED).header(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"" + context.getRealm().getName() + "\"").build(); context.challenge(challengeResponse); return; @@ -62,7 +67,7 @@ public class ClientIdAndSecretAuthenticator extends AbstractClientAuthenticator } } - if (client_id == null) { + if (formData != null && client_id == null) { client_id = formData.getFirst(OAuth2Constants.CLIENT_ID); clientSecret = formData.getFirst("client_secret"); } diff --git a/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java b/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java index 0156fb6803..3865027ef7 100755 --- a/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AppAuthManager.java @@ -3,6 +3,7 @@ package org.keycloak.services.managers; import org.jboss.logging.Logger; import org.jboss.resteasy.spi.UnauthorizedException; import org.keycloak.ClientConnection; +import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -39,6 +40,11 @@ public class AppAuthManager extends AuthenticationManager { return tokenString; } + public AuthResult authenticateBearerToken(KeycloakSession session, RealmModel realm) { + KeycloakContext ctx = session.getContext(); + return authenticateBearerToken(session, realm, ctx.getUri(), ctx.getConnection(), ctx.getRequestHeaders()); + } + public AuthResult authenticateBearerToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, HttpHeaders headers) { String tokenString = extractAuthorizationHeaderToken(headers); if (tokenString == null) return null; diff --git a/services/src/main/java/org/keycloak/services/resources/ClientRegistrationService.java b/services/src/main/java/org/keycloak/services/resources/ClientRegistrationService.java index 87e55b8b69..dac9daa93c 100644 --- a/services/src/main/java/org/keycloak/services/resources/ClientRegistrationService.java +++ b/services/src/main/java/org/keycloak/services/resources/ClientRegistrationService.java @@ -3,21 +3,27 @@ package org.keycloak.services.resources; import org.jboss.logging.Logger; import org.jboss.resteasy.spi.BadRequestException; import org.jboss.resteasy.spi.NotFoundException; +import org.jboss.resteasy.spi.UnauthorizedException; +import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; import org.keycloak.exportimport.ClientDescriptionConverter; import org.keycloak.exportimport.KeycloakClientDescriptionConverter; -import org.keycloak.models.ClientModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.ModelDuplicateException; -import org.keycloak.models.RealmModel; +import org.keycloak.models.*; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; +import org.keycloak.representations.AccessToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.ForbiddenException; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager; import javax.ws.rs.*; import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.net.URI; @@ -36,6 +42,8 @@ public class ClientRegistrationService { @Context private KeycloakSession session; + private AppAuthManager authManager = new AppAuthManager(); + public ClientRegistrationService(RealmModel realm, EventBuilder event) { this.realm = realm; this.event = event; @@ -44,6 +52,10 @@ public class ClientRegistrationService { @POST @Consumes({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, MediaType.TEXT_PLAIN }) public Response create(String description, @QueryParam("format") String format) { + event.event(EventType.CLIENT_REGISTER); + + authenticate(true, null); + if (format == null) { format = KeycloakClientDescriptionConverter.ID; } @@ -58,6 +70,10 @@ public class ClientRegistrationService { ClientModel clientModel = RepresentationToModel.createClient(session, realm, rep, true); rep = ModelToRepresentation.toRepresentation(clientModel); URI uri = session.getContext().getUri().getAbsolutePathBuilder().path(clientModel.getId()).build(); + + logger.infov("Created client {0}", rep.getClientId()); + + event.client(rep.getClientId()).success(); return Response.created(uri).entity(rep).build(); } catch (ModelDuplicateException e) { return ErrorResponse.exists("Client " + rep.getClientId() + " already exists"); @@ -67,34 +83,79 @@ public class ClientRegistrationService { @GET @Path("{clientId}") @Produces(MediaType.APPLICATION_JSON) - public ClientRepresentation get(@PathParam("clientId") String clientId) { - AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, realm); - ClientModel client = clientAuth.getClient(); + public Response get(@PathParam("clientId") String clientId) { + event.event(EventType.CLIENT_INFO); + + ClientModel client = authenticate(false, clientId); if (client == null) { - throw new NotFoundException("Client not found"); + return Response.status(Response.Status.NOT_FOUND).build(); } - return ModelToRepresentation.toRepresentation(client); + return Response.ok(ModelToRepresentation.toRepresentation(client)).build(); } @PUT @Path("{clientId}") @Consumes(MediaType.APPLICATION_JSON) - public void update(@PathParam("clientId") String clientId, ClientRepresentation rep) { - ClientModel client = realm.getClientByClientId(clientId); - if (client == null) { - throw new NotFoundException("Client not found"); - } + public Response update(@PathParam("clientId") String clientId, ClientRepresentation rep) { + event.event(EventType.CLIENT_UPDATE).client(clientId); + + ClientModel client = authenticate(false, clientId); RepresentationToModel.updateClient(rep, client); + + logger.infov("Updated client {0}", rep.getClientId()); + + event.success(); + return Response.status(Response.Status.OK).build(); } @DELETE @Path("{clientId}") - public void delete(@PathParam("clientId") String clientId) { - ClientModel client = realm.getClientByClientId(clientId); - if (client == null) { - throw new NotFoundException("Client not found"); + public Response delete(@PathParam("clientId") String clientId) { + event.event(EventType.CLIENT_DELETE).client(clientId); + + ClientModel client = authenticate(false, clientId); + if (realm.removeClient(client.getId())) { + event.success(); + return Response.ok().build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); } - realm.removeClient(client.getId()); + } + + private ClientModel authenticate(boolean create, String clientId) { + String authorizationHeader = session.getContext().getRequestHeaders().getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION); + + boolean bearer = authorizationHeader != null && authorizationHeader.split(" ")[0].equalsIgnoreCase("Bearer"); + + if (bearer) { + AuthenticationManager.AuthResult authResult = authManager.authenticateBearerToken(session, realm); + AccessToken.Access realmAccess = authResult.getToken().getResourceAccess(Constants.REALM_MANAGEMENT_CLIENT_ID); + if (realmAccess != null) { + if (realmAccess.isUserInRole(AdminRoles.MANAGE_CLIENTS)) { + return create ? null : realm.getClientByClientId(clientId); + } + + if (create && realmAccess.isUserInRole(AdminRoles.CREATE_CLIENT)) { + return create ? null : realm.getClientByClientId(clientId); + } + } + } else if (!create) { + ClientModel client; + + try { + AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, realm); + client = clientAuth.getClient(); + + if (client != null && !client.isPublicClient() && client.getClientId().equals(clientId)) { + return client; + } + } catch (Throwable t) { + } + } + + event.error(Errors.NOT_ALLOWED); + + throw new ForbiddenException(); } } diff --git a/services/src/main/java/org/keycloak/services/resources/RealmsResource.java b/services/src/main/java/org/keycloak/services/resources/RealmsResource.java index 52f49df1ce..dda825fdf3 100755 --- a/services/src/main/java/org/keycloak/services/resources/RealmsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/RealmsResource.java @@ -112,14 +112,14 @@ public class RealmsResource { return service; } -// @Path("{realm}/client-registration") -// public ClientRegistrationService getClientsService(final @PathParam("realm") String name) { -// RealmModel realm = init(name); -// EventBuilder event = new EventBuilder(realm, session, clientConnection); -// ClientRegistrationService service = new ClientRegistrationService(realm, event); -// ResteasyProviderFactory.getInstance().injectProperties(service); -// return service; -// } + @Path("{realm}/client-registration") + public ClientRegistrationService getClientsService(final @PathParam("realm") String name) { + RealmModel realm = init(name); + EventBuilder event = new EventBuilder(realm, session, clientConnection); + ClientRegistrationService service = new ClientRegistrationService(realm, event); + ResteasyProviderFactory.getInstance().injectProperties(service); + return service; + } @Path("{realm}/clients-managements") public ClientsManagementService getClientsManagementService(final @PathParam("realm") String name) { diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/ContainersTestEnricher.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/ContainersTestEnricher.java index ffb3e0c6ab..257fb55689 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/ContainersTestEnricher.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/ContainersTestEnricher.java @@ -20,6 +20,8 @@ import org.keycloak.admin.client.Keycloak; import org.keycloak.models.Constants; import org.keycloak.testsuite.arquillian.annotation.AdapterLibsLocationProperty; import org.keycloak.testsuite.arquillian.annotation.AppServerContainer; +import org.keycloak.testsuite.util.OAuthClient; + import static org.keycloak.testsuite.auth.page.AuthRealm.ADMIN; import static org.keycloak.testsuite.auth.page.AuthRealm.MASTER; @@ -55,6 +57,10 @@ public class ContainersTestEnricher { @ClassScoped private InstanceProducer adminClient; + @Inject + @ClassScoped + private InstanceProducer oauthClient; + private ContainerController controller; private final boolean migrationTests = System.getProperty("migration", "false").equals("true"); @@ -92,6 +98,7 @@ public class ContainersTestEnricher { initializeTestContext(testClass); initializeAdminClient(); + initializeOAuthClient(); } private void initializeTestContext(Class testClass) { @@ -116,6 +123,10 @@ public class ContainersTestEnricher { MASTER, ADMIN, ADMIN, Constants.ADMIN_CONSOLE_CLIENT_ID)); } + private void initializeOAuthClient() { + oauthClient.set(new OAuthClient(getAuthServerContextRootFromSystemProperty() + "/auth")); + } + /** * * @param testClass diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/KeycloakArquillianExtension.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/KeycloakArquillianExtension.java index cf25d05159..73583cf489 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/KeycloakArquillianExtension.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/KeycloakArquillianExtension.java @@ -1,8 +1,6 @@ package org.keycloak.testsuite.arquillian; -import org.keycloak.testsuite.arquillian.provider.URLProvider; -import org.keycloak.testsuite.arquillian.provider.SuiteContextProvider; -import org.keycloak.testsuite.arquillian.provider.TestContextProvider; +import org.keycloak.testsuite.arquillian.provider.*; import org.jboss.arquillian.container.spi.client.container.DeployableContainer; import org.jboss.arquillian.container.test.impl.enricher.resource.URLResourceProvider; import org.jboss.arquillian.container.test.spi.client.deployment.ApplicationArchiveProcessor; @@ -12,7 +10,6 @@ import org.jboss.arquillian.graphene.location.CustomizableURLResourceProvider; import org.jboss.arquillian.test.spi.enricher.resource.ResourceProvider; import org.jboss.arquillian.test.spi.execution.TestExecutionDecider; import org.keycloak.testsuite.arquillian.jira.JiraTestExecutionDecider; -import org.keycloak.testsuite.arquillian.provider.AdminClientProvider; import org.keycloak.testsuite.arquillian.undertow.CustomUndertowContainer; /** @@ -27,7 +24,8 @@ public class KeycloakArquillianExtension implements LoadableExtension { builder .service(ResourceProvider.class, SuiteContextProvider.class) .service(ResourceProvider.class, TestContextProvider.class) - .service(ResourceProvider.class, AdminClientProvider.class); + .service(ResourceProvider.class, AdminClientProvider.class) + .service(ResourceProvider.class, OAuthClientProvider.class); builder .service(DeploymentScenarioGenerator.class, DeploymentTargetModifier.class) diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/provider/OAuthClientProvider.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/provider/OAuthClientProvider.java new file mode 100644 index 0000000000..4f54d18cf7 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/provider/OAuthClientProvider.java @@ -0,0 +1,29 @@ +package org.keycloak.testsuite.arquillian.provider; + +import org.jboss.arquillian.core.api.Instance; +import org.jboss.arquillian.core.api.annotation.Inject; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.jboss.arquillian.test.spi.enricher.resource.ResourceProvider; +import org.keycloak.testsuite.util.OAuthClient; + +import java.lang.annotation.Annotation; + +/** + * @author Stian Thorgersen + */ +public class OAuthClientProvider implements ResourceProvider { + + @Inject + Instance oauthClient; + + @Override + public boolean canProvide(Class type) { + return OAuthClient.class.isAssignableFrom(type); + } + + @Override + public Object lookup(ArquillianResource resource, Annotation... qualifiers) { + return oauthClient.get(); + } + +} 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 new file mode 100644 index 0000000000..3e52bbbc5a --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -0,0 +1,77 @@ +package org.keycloak.testsuite.util; + +import org.apache.commons.io.IOUtils; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.message.BasicNameValuePair; +import org.keycloak.OAuth2Constants; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.util.BasicAuthHelper; +import org.keycloak.util.JsonSerialization; + +import javax.ws.rs.core.UriBuilder; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.LinkedList; +import java.util.List; + +/** + * @author Stian Thorgersen + */ +public class OAuthClient { + + private String baseUrl; + + public OAuthClient(String baseUrl) { + this.baseUrl = baseUrl; + } + + public AccessTokenResponse getToken(String realm, String clientId, String clientSecret, String username, String password) { + CloseableHttpClient httpclient = HttpClients.createDefault(); + try { + HttpPost post = new HttpPost(OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl)).build(realm)); + + List parameters = new LinkedList(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD)); + parameters.add(new BasicNameValuePair("username", username)); + parameters.add(new BasicNameValuePair("password", password)); + if (clientSecret != null) { + String authorization = BasicAuthHelper.createHeader(clientId, clientSecret); + post.setHeader("Authorization", authorization); + } else { + parameters.add(new BasicNameValuePair("client_id", clientId)); + } + + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + CloseableHttpResponse response = httpclient.execute(post); + + if (response.getStatusLine().getStatusCode() != 200) { + throw new RuntimeException("Failed to retrieve token: " + response.getStatusLine().toString() + " / " + IOUtils.toString(response.getEntity().getContent())); + } + + return JsonSerialization.readValue(response.getEntity().getContent(), AccessTokenResponse.class); + } catch (Exception e) { + throw new RuntimeException(e); + } + finally { + try { + httpclient.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java index 33991151ce..99c5d670ce 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java @@ -20,6 +20,7 @@ import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import static org.keycloak.testsuite.admin.Users.setPasswordFor; import org.keycloak.testsuite.arquillian.SuiteContext; +import org.keycloak.testsuite.util.OAuthClient; import org.openqa.selenium.WebDriver; import org.keycloak.testsuite.auth.page.AuthServer; import org.keycloak.testsuite.auth.page.AuthServerContextRoot; @@ -51,6 +52,9 @@ public abstract class AbstractKeycloakTest { @ArquillianResource protected Keycloak adminClient; + @ArquillianResource + protected OAuthClient oauthClient; + protected List testRealmReps; @Drone diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientRegistrationTest.java new file mode 100644 index 0000000000..1d4b01129b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientRegistrationTest.java @@ -0,0 +1,306 @@ +package org.keycloak.testsuite.client; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.client.registration.ClientRegistration; +import org.keycloak.client.registration.ClientRegistrationException; +import org.keycloak.client.registration.HttpErrorException; +import org.keycloak.models.AdminRoles; +import org.keycloak.models.Constants; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * @author Stian Thorgersen + */ +public class ClientRegistrationTest extends AbstractKeycloakTest { + + private static final String REALM_NAME = "test"; + private static final String CLIENT_ID = "test-client"; + private static final String CLIENT_SECRET = "test-client-secret"; + + private ClientRegistration clientRegistrationAsAdmin; + private ClientRegistration clientRegistrationAsClient; + + @Before + public void before() throws ClientRegistrationException { + clientRegistrationAsAdmin = clientBuilder().auth(getToken("manage-clients", "password")).build(); + clientRegistrationAsClient = clientBuilder().auth(CLIENT_ID, CLIENT_SECRET).build(); + } + + @After + public void after() throws ClientRegistrationException { + clientRegistrationAsAdmin.close(); + clientRegistrationAsClient.close(); + } + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation rep = new RealmRepresentation(); + rep.setEnabled(true); + rep.setRealm(REALM_NAME); + rep.setUsers(new LinkedList()); + + LinkedList credentials = new LinkedList<>(); + CredentialRepresentation password = new CredentialRepresentation(); + password.setType(CredentialRepresentation.PASSWORD); + password.setValue("password"); + credentials.add(password); + + UserRepresentation user = new UserRepresentation(); + user.setEnabled(true); + user.setUsername("manage-clients"); + user.setCredentials(credentials); + user.setClientRoles(Collections.singletonMap(Constants.REALM_MANAGEMENT_CLIENT_ID, Collections.singletonList(AdminRoles.MANAGE_CLIENTS))); + + rep.getUsers().add(user); + + UserRepresentation user2 = new UserRepresentation(); + user2.setEnabled(true); + user2.setUsername("create-clients"); + user2.setCredentials(credentials); + user2.setClientRoles(Collections.singletonMap(Constants.REALM_MANAGEMENT_CLIENT_ID, Collections.singletonList(AdminRoles.CREATE_CLIENT))); + + rep.getUsers().add(user2); + + UserRepresentation user3 = new UserRepresentation(); + user3.setEnabled(true); + user3.setUsername("no-access"); + user3.setCredentials(credentials); + + rep.getUsers().add(user3); + + testRealms.add(rep); + } + + private void registerClient(ClientRegistration clientRegistration) throws ClientRegistrationException { + ClientRepresentation client = new ClientRepresentation(); + client.setClientId(CLIENT_ID); + client.setSecret(CLIENT_SECRET); + + ClientRepresentation createdClient = clientRegistration.create(client); + assertEquals(CLIENT_ID, createdClient.getClientId()); + + client = adminClient.realm(REALM_NAME).clients().get(createdClient.getId()).toRepresentation(); + assertEquals(CLIENT_ID, client.getClientId()); + + AccessTokenResponse token2 = oauthClient.getToken(REALM_NAME, CLIENT_ID, CLIENT_SECRET, "manage-clients", "password"); + assertNotNull(token2.getToken()); + } + + @Test + public void registerClientAsAdmin() throws ClientRegistrationException { + registerClient(clientRegistrationAsAdmin); + } + + @Test + public void registerClientAsAdminWithCreateOnly() throws ClientRegistrationException { + ClientRegistration clientRegistration = clientBuilder().auth(getToken("create-clients", "password")).build(); + try { + registerClient(clientRegistration); + } finally { + clientRegistration.close(); + } + } + + @Test + public void registerClientAsAdminWithNoAccess() throws ClientRegistrationException { + ClientRegistration clientRegistration = clientBuilder().auth(getToken("no-access", "password")).build(); + try { + registerClient(clientRegistration); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } finally { + clientRegistration.close(); + } + } + + @Test + public void getClientAsAdminWithCreateOnly() throws ClientRegistrationException { + registerClient(clientRegistrationAsAdmin); + ClientRegistration clientRegistration = clientBuilder().auth(getToken("create-clients", "password")).build(); + try { + clientRegistration.get(CLIENT_ID); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } finally { + clientRegistration.close(); + } + } + + @Test + public void wrongClient() throws ClientRegistrationException { + registerClient(clientRegistrationAsAdmin); + + ClientRepresentation client = new ClientRepresentation(); + client.setClientId("test-client-2"); + client.setSecret("test-client-2-secret"); + + clientRegistrationAsAdmin.create(client); + + ClientRegistration clientRegistration = clientBuilder().auth("test-client-2", "test-client-2-secret").build(); + + client = clientRegistration.get("test-client-2"); + assertNotNull(client); + assertEquals("test-client-2", client.getClientId()); + + try { + try { + clientRegistration.get(CLIENT_ID); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } + + client = clientRegistrationAsAdmin.get(CLIENT_ID); + try { + clientRegistration.update(client); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } + + try { + clientRegistration.delete(CLIENT_ID); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } + } + finally { + clientRegistration.close(); + } + } + + @Test + public void getClientAsAdminWithNoAccess() throws ClientRegistrationException { + registerClient(clientRegistrationAsAdmin); + ClientRegistration clientRegistration = clientBuilder().auth(getToken("no-access", "password")).build(); + try { + clientRegistration.get(CLIENT_ID); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } finally { + clientRegistration.close(); + } + } + + private void updateClient(ClientRegistration clientRegistration) throws ClientRegistrationException { + ClientRepresentation client = clientRegistration.get(CLIENT_ID); + client.setRedirectUris(Collections.singletonList("http://localhost:8080/app")); + + clientRegistration.update(client); + + ClientRepresentation updatedClient = clientRegistration.get(CLIENT_ID); + + assertEquals(1, updatedClient.getRedirectUris().size()); + assertEquals("http://localhost:8080/app", updatedClient.getRedirectUris().get(0)); + } + + @Test + public void updateClientAsAdmin() throws ClientRegistrationException { + registerClient(clientRegistrationAsAdmin); + updateClient(clientRegistrationAsAdmin); + } + + @Test + public void updateClientAsAdminWithCreateOnly() throws ClientRegistrationException { + ClientRegistration clientRegistration = clientBuilder().auth(getToken("create-clients", "password")).build(); + try { + updateClient(clientRegistration); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } finally { + clientRegistration.close(); + } + } + + @Test + public void updateClientAsAdminWithNoAccess() throws ClientRegistrationException { + ClientRegistration clientRegistration = clientBuilder().auth(getToken("no-access", "password")).build(); + try { + updateClient(clientRegistration); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } finally { + clientRegistration.close(); + } + } + + @Test + public void updateClientAsClient() throws ClientRegistrationException { + registerClient(clientRegistrationAsAdmin); + updateClient(clientRegistrationAsClient); + } + + private void deleteClient(ClientRegistration clientRegistration) throws ClientRegistrationException { + clientRegistration.delete(CLIENT_ID); + + // Can't authenticate as client after client is deleted + ClientRepresentation client = clientRegistrationAsAdmin.get(CLIENT_ID); + assertNull(client); + } + + @Test + public void deleteClientAsAdmin() throws ClientRegistrationException { + registerClient(clientRegistrationAsAdmin); + deleteClient(clientRegistrationAsAdmin); + } + + @Test + public void deleteClientAsAdminWithCreateOnly() throws ClientRegistrationException { + ClientRegistration clientRegistration = clientBuilder().auth(getToken("create-clients", "password")).build(); + try { + deleteClient(clientRegistration); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } finally { + clientRegistration.close(); + } + } + + @Test + public void deleteClientAsAdminWithNoAccess() throws ClientRegistrationException { + ClientRegistration clientRegistration = clientBuilder().auth(getToken("no-access", "password")).build(); + try { + deleteClient(clientRegistration); + fail("Expected 403"); + } catch (ClientRegistrationException e) { + assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } finally { + clientRegistration.close(); + } + } + + @Test + public void deleteClientAsClient() throws ClientRegistrationException { + registerClient(clientRegistrationAsAdmin); + deleteClient(clientRegistrationAsClient); + } + + private ClientRegistration.ClientRegistrationBuilder clientBuilder() { + return ClientRegistration.create().realm("test").authServerUrl(testContext.getAuthServerContextRoot() + "/auth"); + } + + private String getToken(String username, String password) { + return oauthClient.getToken(REALM_NAME, "security-admin-console", null, username, password).getToken(); + } + +} diff --git a/testsuite/integration-arquillian/tests/pom.xml b/testsuite/integration-arquillian/tests/pom.xml index 8f900b9553..58d32e4ba2 100644 --- a/testsuite/integration-arquillian/tests/pom.xml +++ b/testsuite/integration-arquillian/tests/pom.xml @@ -201,6 +201,10 @@ org.keycloak keycloak-admin-client + + org.keycloak + keycloak-client-api + org.keycloak keycloak-services