From f29aff4bed92fc5794ad17eb144aaa9084137b87 Mon Sep 17 00:00:00 2001 From: Libor Krzyzanek Date: Wed, 7 Oct 2015 13:53:25 +0200 Subject: [PATCH 1/3] Add 'register' and 'createRegisterUrl' methods to Javascript Adapter API. fixes #KEYCLOAK-1904 --- .../en/en-US/modules/javascript-adapter.xml | 15 +++++++++++ examples/js-console/README.md | 2 +- .../js-console/src/main/webapp/index.html | 2 ++ integration/js/src/main/resources/keycloak.js | 27 +++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docbook/auth-server-docs/reference/en/en-US/modules/javascript-adapter.xml b/docbook/auth-server-docs/reference/en/en-US/modules/javascript-adapter.xml index 2e9f941ed8..33d62dca2d 100755 --- a/docbook/auth-server-docs/reference/en/en-US/modules/javascript-adapter.xml +++ b/docbook/auth-server-docs/reference/en/en-US/modules/javascript-adapter.xml @@ -209,6 +209,7 @@ new Keycloak({ url: 'http://localhost/auth', realm: 'myrealm', clientId: 'myApp' redirectUri - specifies the uri to redirect to after login prompt - can be set to 'none' to check if the user is logged in already (if not logged in, a login form is not displayed) loginHint - used to pre-fill the username/email field on the login form + action - if value is 'register' then user is redirected to registration page, otherwise to login page @@ -246,6 +247,20 @@ new Keycloak({ url: 'http://localhost/auth', realm: 'myrealm', clientId: 'myApp' + + register(options) + + Redirects to registration form. It's a shortcut for doing login with option action = 'register' + Options are same as login method but 'action' is overwritten to 'register' + + + + createRegisterUrl(options) + + Returns the url to registration page. It's a shortcut for doing createRegisterUrl with option action = 'register' + Options are same as createLoginUrl method but 'action' is overwritten to 'register' + + accountManagement() diff --git a/examples/js-console/README.md b/examples/js-console/README.md index 749c0f3612..fa9a02d192 100644 --- a/examples/js-console/README.md +++ b/examples/js-console/README.md @@ -12,6 +12,6 @@ Open the Keycloak admin console, click on Add Realm, click on 'Choose a JSON fil Deploy the JS Console to Keycloak by running: - mvn install jboss-as:deploy + mvn install wildfly:deploy Open the console at http://localhost:8080/js-console and login with username: 'user', and password: 'password'. diff --git a/examples/js-console/src/main/webapp/index.html b/examples/js-console/src/main/webapp/index.html index d85b63b811..2cca0f8d16 100644 --- a/examples/js-console/src/main/webapp/index.html +++ b/examples/js-console/src/main/webapp/index.html @@ -7,6 +7,7 @@
+ @@ -18,6 +19,7 @@ +

Result

diff --git a/integration/js/src/main/resources/keycloak.js b/integration/js/src/main/resources/keycloak.js index f384e7b5ea..d189a361f8 100755 --- a/integration/js/src/main/resources/keycloak.js +++ b/integration/js/src/main/resources/keycloak.js @@ -183,6 +183,18 @@ return url; } + kc.register = function (options) { + return adapter.register(options); + } + + kc.createRegisterUrl = function(options) { + if (!options) { + options = {}; + } + options.action = 'register'; + return kc.createLoginUrl(options); + } + kc.createAccountUrl = function(options) { var url = getRealmUrl() + '/account' @@ -760,6 +772,11 @@ return createPromise().promise; }, + register: function(options) { + window.location.href = kc.createRegisterUrl(options); + return createPromise().promise; + }, + accountManagement : function() { window.location.href = kc.createAccountUrl(); return createPromise().promise; @@ -858,6 +875,16 @@ return promise.promise; }, + register : function() { + var registerUrl = kc.createRegisterUrl(); + var ref = window.open(registerUrl, '_blank', 'location=no'); + ref.addEventListener('loadstart', function(event) { + if (event.url.indexOf('http://localhost') == 0) { + ref.close(); + } + }); + }, + accountManagement : function() { var accountUrl = kc.createAccountUrl(); var ref = window.open(accountUrl, '_blank', 'location=no'); From c9437595b70810c4472325373dd8833c37be8549 Mon Sep 17 00:00:00 2001 From: Stan Silvert Date: Wed, 7 Oct 2015 11:34:34 -0400 Subject: [PATCH 2/3] KEYCLOAK-1152 i18n for text hard-coded in java source (ProtocolMapperUtils) --- .../admin/messages/admin-messages_de.properties | 13 +++++++++++++ .../admin/messages/admin-messages_en.properties | 13 +++++++++++++ .../resources/templates/kc-provider-config.html | 12 ++++++------ .../keycloak/protocol/ProtocolMapperUtils.java | 16 ++++++++-------- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_de.properties b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_de.properties index 6442e6e05a..beaeb4dcc2 100644 --- a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_de.properties +++ b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_de.properties @@ -7,6 +7,7 @@ onText=AN offText=AUS client=de Client clear=de Clear +selectOne=de Select One... # Realm settings realm-detail.enabled.tooltip=de Users and clients can only access a realm if it's enabled @@ -114,3 +115,15 @@ not-before.tooltip=de Revoke any tokens issued before this date. set-to-now=de Set To Now push=de Push push.tooltip=de For every client that has an admin URL, notify them of the new revocation policy. + +#Protocol Mapper +usermodel.prop.label=de Property +usermodel.prop.tooltip=de Name of the property method in the UserModel interface. For example, a value of 'email' would reference the UserModel.getEmail() method. +usermodel.attr.label=de User Attribute +usermodel.attr.tooltip=de Name of stored user attribute which is the name of an attribute within the UserModel.attribute map. +userSession.modelNote.label=de User Session Note +userSession.modelNote.tooltip=de Name of stored user session note within the UserSessionModel.note map. +multivalued.label=de Multivalued +multivalued.tooltip=de Indicates if attribute supports multiple values. If true, then the list of all values of this attribute will be set as claim. If false, then just first value will be set as claim +selectRole.label=de Select Role +selectRole.tooltip=de Enter role in the textbox to the left, or click this button to browse and select the role you want diff --git a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index be3ef2de22..54044587e4 100644 --- a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -7,6 +7,7 @@ onText=ON offText=OFF client=Client clear=Clear +selectOne=Select One... # Realm settings realm-detail.enabled.tooltip=Users and clients can only access a realm if it's enabled @@ -114,3 +115,15 @@ not-before.tooltip=Revoke any tokens issued before this date. set-to-now=Set To Now push=Push push.tooltip=For every client that has an admin URL, notify them of the new revocation policy. + +#Protocol Mapper +usermodel.prop.label=Property +usermodel.prop.tooltip=Name of the property method in the UserModel interface. For example, a value of 'email' would reference the UserModel.getEmail() method. +usermodel.attr.label=User Attribute +usermodel.attr.tooltip=Name of stored user attribute which is the name of an attribute within the UserModel.attribute map. +userSession.modelNote.label=User Session Note +userSession.modelNote.tooltip=Name of stored user session note within the UserSessionModel.note map. +multivalued.label=Multivalued +multivalued.tooltip=Indicates if attribute supports multiple values. If true, then the list of all values of this attribute will be set as claim. If false, then just first value will be set as claim +selectRole.label=Select Role +selectRole.tooltip=Enter role in the textbox to the left, or click this button to browse and select the role you want diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html index 08b76a82b1..54ebdaee3e 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html @@ -1,16 +1,16 @@
- +
- +
@@ -19,16 +19,16 @@
- +
- {{option.helpText}} + {{:: option.helpText | translate}} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java b/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java index 829e8635e5..8ba48825c1 100755 --- a/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java +++ b/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java @@ -16,14 +16,14 @@ public class ProtocolMapperUtils { public static final String USER_ATTRIBUTE = "user.attribute"; public static final String USER_SESSION_NOTE = "user.session.note"; public static final String MULTIVALUED = "multivalued"; - public static final String USER_MODEL_PROPERTY_LABEL = "User Property"; - public static final String USER_MODEL_PROPERTY_HELP_TEXT = "Name of the property method in the UserModel interface. For example, a value of 'email' would reference the UserModel.getEmail() method."; - public static final String USER_MODEL_ATTRIBUTE_LABEL = "User Attribute"; - public static final String USER_MODEL_ATTRIBUTE_HELP_TEXT = "Name of stored user attribute which is the name of an attribute within the UserModel.attribute map."; - public static final String USER_SESSION_MODEL_NOTE_LABEL = "User Session Note"; - public static final String USER_SESSION_MODEL_NOTE_HELP_TEXT = "Name of stored user session note within the UserSessionModel.note map."; - public static final String MULTIVALUED_LABEL = "Multivalued"; - public static final String MULTIVALUED_HELP_TEXT = "Indicates if attribute supports multiple values. If true, then the list of all values of this attribute will be set as claim. If false, then just first value will be set as claim"; + public static final String USER_MODEL_PROPERTY_LABEL = "usermodel.prop.label"; + public static final String USER_MODEL_PROPERTY_HELP_TEXT = "usermodel.prop.tooltip"; + public static final String USER_MODEL_ATTRIBUTE_LABEL = "usermodel.attr.label"; + public static final String USER_MODEL_ATTRIBUTE_HELP_TEXT = "usermodel.attr.tooltip"; + public static final String USER_SESSION_MODEL_NOTE_LABEL = "userSession.modelNote.label"; + public static final String USER_SESSION_MODEL_NOTE_HELP_TEXT = "userSession.modelNote.tooltip"; + public static final String MULTIVALUED_LABEL = "multivalued.label"; + public static final String MULTIVALUED_HELP_TEXT = "multivalued.tooltip"; public static String getUserModelValue(UserModel user, String propertyName) { From 366a1629e5bfa1159d8e4a3cd3f3e35e6cecd26b Mon Sep 17 00:00:00 2001 From: Stian Thorgersen Date: Wed, 30 Sep 2015 13:13:17 +0200 Subject: [PATCH 3/3] KEYCLOAK-1749 Client registration service and client java api --- client-api/pom.xml | 31 ++ .../registration/ClientRegistration.java | 275 ++++++++++++++++ .../ClientRegistrationException.java | 16 + .../registration/HttpErrorException.java | 22 ++ .../idm/UserRepresentation.java | 6 +- .../java/org/keycloak/events/EventType.java | 11 +- .../login/messages/messages_en.properties | 1 + .../migration/migrators/MigrateTo1_6_0.java | 9 + .../java/org/keycloak/models/AdminRoles.java | 3 +- pom.xml | 6 + .../ClientIdAndSecretAuthenticator.java | 11 +- .../services/managers/AppAuthManager.java | 6 + .../resources/ClientRegistrationService.java | 99 ++++-- .../services/resources/RealmsResource.java | 16 +- .../arquillian/ContainersTestEnricher.java | 11 + .../KeycloakArquillianExtension.java | 8 +- .../provider/OAuthClientProvider.java | 29 ++ .../keycloak/testsuite/util/OAuthClient.java | 77 +++++ .../testsuite/AbstractKeycloakTest.java | 4 + .../client/ClientRegistrationTest.java | 306 ++++++++++++++++++ .../integration-arquillian/tests/pom.xml | 4 + 21 files changed, 910 insertions(+), 41 deletions(-) create mode 100755 client-api/pom.xml create mode 100644 client-api/src/main/java/org/keycloak/client/registration/ClientRegistration.java create mode 100644 client-api/src/main/java/org/keycloak/client/registration/ClientRegistrationException.java create mode 100644 client-api/src/main/java/org/keycloak/client/registration/HttpErrorException.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/provider/OAuthClientProvider.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientRegistrationTest.java 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