From a8fb988e311fe8550c1a20c2d70e40bb67face07 Mon Sep 17 00:00:00 2001 From: mposolda Date: Tue, 9 Aug 2016 12:32:43 +0200 Subject: [PATCH 1/2] KEYCLOAK-3406 OIDC dynamic client registrations specs fixes --- .../jaas/DirectAccessGrantsLoginModule.java | 13 +- .../org/keycloak/OAuthErrorException.java | 10 +- .../idm/OAuth2ErrorRepresentation.java | 56 +++++ .../oidc/OIDCClientRepresentation.java | 201 +++++++++++++++++- .../java/org/keycloak/JsonParserTest.java | 12 ++ .../registration/HttpErrorException.java | 23 +- .../client/registration/HttpUtil.java | 34 ++- .../authenticators/client/ClientAuthUtil.java | 13 +- .../AbstractDirectGrantAuthenticator.java | 9 +- .../services/ErrorResponseException.java | 9 +- .../AbstractClientRegistrationProvider.java | 3 +- .../ClientRegistrationAuth.java | 47 ++-- .../ClientRegistrationTokenUtils.java | 47 +++- .../ClientRegistrationUriUtils.java | 7 + .../oidc/DescriptionConverter.java | 2 + .../resources/ClientsManagementService.java | 14 +- .../client/OIDCClientRegistrationTest.java | 28 ++- .../oauth/TokenIntrospectionTest.java | 11 +- 18 files changed, 445 insertions(+), 94 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/idm/OAuth2ErrorRepresentation.java create mode 100644 services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationUriUtils.java diff --git a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/jaas/DirectAccessGrantsLoginModule.java b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/jaas/DirectAccessGrantsLoginModule.java index ddd11521bb..78d98e779d 100755 --- a/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/jaas/DirectAccessGrantsLoginModule.java +++ b/adapters/oidc/adapter-core/src/main/java/org/keycloak/adapters/jaas/DirectAccessGrantsLoginModule.java @@ -43,6 +43,7 @@ import org.keycloak.common.VerificationException; import org.keycloak.adapters.authentication.ClientCredentialsProviderUtils; import org.keycloak.constants.ServiceUrlConstants; import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.util.JsonSerialization; import org.keycloak.common.util.KeycloakUriBuilder; @@ -102,9 +103,9 @@ public class DirectAccessGrantsLoginModule extends AbstractKeycloakLoginModule { StringBuilder errorBuilder = new StringBuilder("Login failed. Invalid status: " + status); if (entity != null) { InputStream is = entity.getContent(); - Map errors = (Map) JsonSerialization.readValue(is, Map.class); - errorBuilder.append(", OAuth2 error. Error: " + errors.get(OAuth2Constants.ERROR)) - .append(", Error description: " + errors.get(OAuth2Constants.ERROR_DESCRIPTION)); + OAuth2ErrorRepresentation errorRep = JsonSerialization.readValue(is, OAuth2ErrorRepresentation.class); + errorBuilder.append(", OAuth2 error. Error: " + errorRep.getError()) + .append(", Error description: " + errorRep.getErrorDescription()); } String error = errorBuilder.toString(); log.warn(error); @@ -161,9 +162,9 @@ public class DirectAccessGrantsLoginModule extends AbstractKeycloakLoginModule { if (entity != null) { InputStream is = entity.getContent(); if (status == 400) { - Map errors = (Map) JsonSerialization.readValue(is, Map.class); - errorBuilder.append(", OAuth2 error. Error: " + errors.get(OAuth2Constants.ERROR)) - .append(", Error description: " + errors.get(OAuth2Constants.ERROR_DESCRIPTION)); + OAuth2ErrorRepresentation errorRep = JsonSerialization.readValue(is, OAuth2ErrorRepresentation.class); + errorBuilder.append(", OAuth2 error. Error: " + errorRep.getError()) + .append(", Error description: " + errorRep.getErrorDescription()); } else { if (is != null) is.close(); diff --git a/core/src/main/java/org/keycloak/OAuthErrorException.java b/core/src/main/java/org/keycloak/OAuthErrorException.java index f0bb862cb5..940d434c20 100755 --- a/core/src/main/java/org/keycloak/OAuthErrorException.java +++ b/core/src/main/java/org/keycloak/OAuthErrorException.java @@ -37,12 +37,18 @@ public class OAuthErrorException extends Exception { public static final String REQUEST_NOT_SUPPORTED = "request_not_supported"; public static final String REQUEST_URI_NOT_SUPPORTED = "request_uri_not_supported"; + // OAuth2 Bearer Token Usage + public static final String INVALID_TOKEN = "invalid_token"; + public static final String INSUFFICIENT_SCOPE = "insufficient_scope"; + + // OIDC Dynamic Client Registration + public static final String INVALID_REDIRECT_URI = "invalid_redirect_uri"; + public static final String INVALID_CLIENT_METADATA = "invalid_client_metadata"; + // Others public static final String INVALID_CLIENT = "invalid_client"; public static final String INVALID_GRANT = "invalid_grant"; public static final String UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type"; - public static final String INVALID_TOKEN = "invalid_token"; - public static final String INSUFFICIENT_SCOPE = "insufficient_scope"; public OAuthErrorException(String error, String description, String message, Throwable cause) { super(message, cause); diff --git a/core/src/main/java/org/keycloak/representations/idm/OAuth2ErrorRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/OAuth2ErrorRepresentation.java new file mode 100644 index 0000000000..f9880139de --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/OAuth2ErrorRepresentation.java @@ -0,0 +1,56 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.representations.idm; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.OAuth2Constants; + +/** + * @author Marek Posolda + */ +public class OAuth2ErrorRepresentation { + + private String error; + private String errorDescription; + + public OAuth2ErrorRepresentation() { + } + + public OAuth2ErrorRepresentation(String error, String errorDescription) { + this.error = error; + this.errorDescription = errorDescription; + } + + @JsonProperty(OAuth2Constants.ERROR) + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + @JsonProperty(OAuth2Constants.ERROR_DESCRIPTION) + public String getErrorDescription() { + return errorDescription; + } + + public void setErrorDescription(String errorDescription) { + this.errorDescription = errorDescription; + } +} diff --git a/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java b/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java index fc2973d5bc..26082db2e7 100644 --- a/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java @@ -27,14 +27,20 @@ import java.util.List; @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, setterVisibility = JsonAutoDetect.Visibility.NONE) public class OIDCClientRepresentation { + // OIDC Dynamic client registration properties + private List redirect_uris; private String token_endpoint_auth_method; + private String token_endpoint_auth_signing_alg; + private List grant_types; private List response_types; + private String application_type; + private String client_id; private String client_secret; @@ -47,7 +53,7 @@ public class OIDCClientRepresentation { private String scope; - private String contacts; + private List contacts; private String tos_uri; @@ -57,10 +63,47 @@ public class OIDCClientRepresentation { private String jwks; + private String sector_identifier_uri; + + private String subject_type; + + private String id_token_signed_response_alg; + + private String id_token_encrypted_response_alg; + + private String id_token_encrypted_response_enc; + + private String userinfo_signed_response_alg; + + private String userinfo_encrypted_response_alg; + + private String userinfo_encrypted_response_enc; + + private String request_object_signing_alg; + + private String request_object_encryption_alg; + + private String request_object_encryption_enc; + + private Integer default_max_age; + + private Boolean require_auth_time; + + private List default_acr_values; + + private String initiate_login_uri; + + private List request_uris; + + // OIDC Session Management + private List post_logout_redirect_uris; + + // Not sure from which specs this comes private String software_id; private String software_version; + // OIDC Dynamic Client Registration Response private Integer client_id_issued_at; private Integer client_secret_expires_at; @@ -85,6 +128,14 @@ public class OIDCClientRepresentation { this.token_endpoint_auth_method = token_endpoint_auth_method; } + public String getTokenEndpointAuthSigningAlg() { + return token_endpoint_auth_signing_alg; + } + + public void setTokenEndpointAuthSigningAlg(String token_endpoint_auth_signing_alg) { + this.token_endpoint_auth_signing_alg = token_endpoint_auth_signing_alg; + } + public List getGrantTypes() { return grant_types; } @@ -101,6 +152,14 @@ public class OIDCClientRepresentation { this.response_types = responseTypes; } + public String getApplicationType() { + return application_type; + } + + public void setApplicationType(String applicationType) { + this.application_type = applicationType; + } + public String getClientId() { return client_id; } @@ -149,11 +208,11 @@ public class OIDCClientRepresentation { this.scope = scope; } - public String getContacts() { + public List getContacts() { return contacts; } - public void setContacts(String contacts) { + public void setContacts(List contacts) { this.contacts = contacts; } @@ -189,6 +248,142 @@ public class OIDCClientRepresentation { this.jwks = jwks; } + public String getSectorIdentifierUri() { + return sector_identifier_uri; + } + + public void setSectorIdentifierUri(String sectorIdentifierUri) { + this.sector_identifier_uri = sectorIdentifierUri; + } + + public String getSubjectType() { + return subject_type; + } + + public void setSubjectType(String subjectType) { + this.subject_type = subjectType; + } + + public String getIdTokenSignedResponseAlg() { + return id_token_signed_response_alg; + } + + public void setIdTokenSignedResponseAlg(String idTokenSignedResponseAlg) { + this.id_token_signed_response_alg = idTokenSignedResponseAlg; + } + + public String getIdTokenEncryptedResponseAlg() { + return id_token_encrypted_response_alg; + } + + public void setIdTokenEncryptedResponseAlg(String idTokenEncryptedResponseAlg) { + this.id_token_encrypted_response_alg = idTokenEncryptedResponseAlg; + } + + public String getIdTokenEncryptedResponseEnc() { + return id_token_encrypted_response_enc; + } + + public void setIdTokenEncryptedResponseEnc(String idTokenEncryptedResponseEnc) { + this.id_token_encrypted_response_enc = idTokenEncryptedResponseEnc; + } + + public String getUserinfoSignedResponseAlg() { + return userinfo_signed_response_alg; + } + + public void setUserinfoSignedResponseAlg(String userinfo_signed_response_alg) { + this.userinfo_signed_response_alg = userinfo_signed_response_alg; + } + + public String getUserinfoEncryptedResponseAlg() { + return userinfo_encrypted_response_alg; + } + + public void setUserinfoEncryptedResponseAlg(String userinfo_encrypted_response_alg) { + this.userinfo_encrypted_response_alg = userinfo_encrypted_response_alg; + } + + public String getUserinfoEncryptedResponseEnc() { + return userinfo_encrypted_response_enc; + } + + public void setUserinfoEncryptedResponseEnc(String userinfo_encrypted_response_enc) { + this.userinfo_encrypted_response_enc = userinfo_encrypted_response_enc; + } + + public String getRequestObjectSigningAlg() { + return request_object_signing_alg; + } + + public void setRequestObjectSigningAlg(String request_object_signing_alg) { + this.request_object_signing_alg = request_object_signing_alg; + } + + public String getRequestObjectEncryptionAlg() { + return request_object_encryption_alg; + } + + public void setRequestObjectEncryptionAlg(String request_object_encryption_alg) { + this.request_object_encryption_alg = request_object_encryption_alg; + } + + public String getRequestObjectEncryptionEnc() { + return request_object_encryption_enc; + } + + public void setRequestObjectEncryptionEnc(String request_object_encryption_enc) { + this.request_object_encryption_enc = request_object_encryption_enc; + } + + public Integer getDefaultMaxAge() { + return default_max_age; + } + + public void setDefaultMaxAge(Integer default_max_age) { + this.default_max_age = default_max_age; + } + + public Boolean getRequireAuthTime() { + return require_auth_time; + } + + public void setRequireAuthTime(Boolean require_auth_time) { + this.require_auth_time = require_auth_time; + } + + public List getDefaultAcrValues() { + return default_acr_values; + } + + public void setDefaultAcrValues(List default_acr_values) { + this.default_acr_values = default_acr_values; + } + + public String getInitiateLoginUri() { + return initiate_login_uri; + } + + public void setInitiateLoginUri(String initiate_login_uri) { + this.initiate_login_uri = initiate_login_uri; + } + + public List getRequestUris() { + return request_uris; + } + + public void setRequestUris(List requestUris) { + this.request_uris = requestUris; + } + + public List getPostLogoutRedirectUris() { + return post_logout_redirect_uris; + } + + public void setPostLogoutRedirectUris(List post_logout_redirect_uris) { + this.post_logout_redirect_uris = post_logout_redirect_uris; + } + public String getSoftwareId() { return software_id; } diff --git a/core/src/test/java/org/keycloak/JsonParserTest.java b/core/src/test/java/org/keycloak/JsonParserTest.java index 69416b70f8..062dc9bd10 100755 --- a/core/src/test/java/org/keycloak/JsonParserTest.java +++ b/core/src/test/java/org/keycloak/JsonParserTest.java @@ -33,6 +33,7 @@ import org.junit.Test; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.adapters.config.AdapterConfig; +import org.keycloak.representations.oidc.OIDCClientRepresentation; import org.keycloak.util.JsonSerialization; /** @@ -125,4 +126,15 @@ public class JsonParserTest { System.out.println(sb.toString()); } + @Test + public void testReadOIDCClientRep() throws IOException { + String stringRep = "{\"subject_type\": \"public\", \"jwks_uri\": \"https://op.certification.openid.net:60720/export/jwk_60720.json\", \"contacts\": [\"roland.hedberg@umu.se\"], \"application_type\": \"web\", \"grant_types\": [\"authorization_code\"], \"post_logout_redirect_uris\": [\"https://op.certification.openid.net:60720/logout\"], \"redirect_uris\": [\"https://op.certification.openid.net:60720/authz_cb\"], \"response_types\": [\"code\"], \"require_auth_time\": true, \"default_max_age\": 3600}"; + OIDCClientRepresentation clientRep = JsonSerialization.readValue(stringRep, OIDCClientRepresentation.class); + Assert.assertEquals("public", clientRep.getSubjectType()); + Assert.assertTrue(clientRep.getRequireAuthTime()); + Assert.assertEquals(3600, clientRep.getDefaultMaxAge().intValue()); + Assert.assertEquals(1, clientRep.getRedirectUris().size()); + Assert.assertEquals("https://op.certification.openid.net:60720/authz_cb", clientRep.getRedirectUris().get(0)); + } + } diff --git a/integration/client-registration/src/main/java/org/keycloak/client/registration/HttpErrorException.java b/integration/client-registration/src/main/java/org/keycloak/client/registration/HttpErrorException.java index 4078b73557..640bc0d4a3 100644 --- a/integration/client-registration/src/main/java/org/keycloak/client/registration/HttpErrorException.java +++ b/integration/client-registration/src/main/java/org/keycloak/client/registration/HttpErrorException.java @@ -18,6 +18,8 @@ package org.keycloak.client.registration; import org.apache.http.StatusLine; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; +import org.keycloak.util.JsonSerialization; import java.io.IOException; @@ -26,14 +28,31 @@ import java.io.IOException; */ public class HttpErrorException extends IOException { - private StatusLine statusLine; + private final StatusLine statusLine; + private final String errorResponse; - public HttpErrorException(StatusLine statusLine) { + public HttpErrorException(StatusLine statusLine, String errorResponse) { this.statusLine = statusLine; + this.errorResponse = errorResponse; } public StatusLine getStatusLine() { return statusLine; } + public String getErrorResponse() { + return errorResponse; + } + + public OAuth2ErrorRepresentation toErrorRepresentation() { + if (errorResponse == null) { + return null; + } + + try { + return JsonSerialization.readValue(errorResponse, OAuth2ErrorRepresentation.class); + } catch (IOException ioe) { + throw new RuntimeException("Not OAuth2 error"); + } + } } diff --git a/integration/client-registration/src/main/java/org/keycloak/client/registration/HttpUtil.java b/integration/client-registration/src/main/java/org/keycloak/client/registration/HttpUtil.java index 8d524cee47..66808ed8d7 100644 --- a/integration/client-registration/src/main/java/org/keycloak/client/registration/HttpUtil.java +++ b/integration/client-registration/src/main/java/org/keycloak/client/registration/HttpUtil.java @@ -23,9 +23,7 @@ import org.apache.http.client.HttpClient; import org.apache.http.client.methods.*; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; -import org.keycloak.client.registration.Auth; -import org.keycloak.client.registration.ClientRegistrationException; -import org.keycloak.client.registration.HttpErrorException; +import org.keycloak.common.util.StreamUtil; import java.io.IOException; import java.io.InputStream; @@ -69,8 +67,7 @@ class HttpUtil { if (response.getStatusLine().getStatusCode() == 201) { return responseStream; } else { - responseStream.close(); - throw new HttpErrorException(response.getStatusLine()); + throw httpErrorException(response, responseStream); } } catch (IOException e) { throw new ClientRegistrationException("Failed to send request", e); @@ -97,10 +94,7 @@ class HttpUtil { responseStream.close(); return null; } else { - if (responseStream != null) { - responseStream.close(); - } - throw new HttpErrorException(response.getStatusLine()); + throw httpErrorException(response, responseStream); } } catch (IOException e) { throw new ClientRegistrationException("Failed to send request", e); @@ -118,9 +112,6 @@ class HttpUtil { addAuth(request); HttpResponse response = httpClient.execute(request); - if (response.getEntity() != null) { - response.getEntity().getContent(); - } InputStream responseStream = null; if (response.getEntity() != null) { @@ -130,10 +121,7 @@ class HttpUtil { if (response.getStatusLine().getStatusCode() == 200) { return responseStream; } else { - if (responseStream != null) { - responseStream.close(); - } - throw new HttpErrorException(response.getStatusLine()); + throw httpErrorException(response, responseStream); } } catch (IOException e) { throw new ClientRegistrationException("Failed to send request", e); @@ -147,12 +135,13 @@ class HttpUtil { addAuth(request); HttpResponse response = httpClient.execute(request); + InputStream responseStream = null; if (response.getEntity() != null) { - response.getEntity().getContent().close(); + responseStream = response.getEntity().getContent(); } if (response.getStatusLine().getStatusCode() != 204) { - throw new HttpErrorException(response.getStatusLine()); + throw httpErrorException(response, responseStream); } } catch (IOException e) { throw new ClientRegistrationException("Failed to send request", e); @@ -185,4 +174,13 @@ class HttpUtil { } } + private HttpErrorException httpErrorException(HttpResponse response, InputStream responseStream) throws IOException { + if (responseStream != null) { + String errorResponse = StreamUtil.readString(responseStream); + return new HttpErrorException(response.getStatusLine(), errorResponse); + } else { + return new HttpErrorException(response.getStatusLine(), null); + } + } + } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientAuthUtil.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientAuthUtil.java index 0d7d91106a..94cefa3297 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientAuthUtil.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientAuthUtil.java @@ -17,13 +17,10 @@ package org.keycloak.authentication.authenticators.client; -import java.util.HashMap; -import java.util.Map; - import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import org.keycloak.OAuth2Constants; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; /** * @author Marek Posolda @@ -32,12 +29,8 @@ public class ClientAuthUtil { public static Response errorResponse(int status, String error, String errorDescription) { - Map e = new HashMap(); - e.put(OAuth2Constants.ERROR, error); - if (errorDescription != null) { - e.put(OAuth2Constants.ERROR_DESCRIPTION, errorDescription); - } - return Response.status(status).entity(e).type(MediaType.APPLICATION_JSON_TYPE).build(); + OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription); + return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build(); } } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/AbstractDirectGrantAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/AbstractDirectGrantAuthenticator.java index 8de42309d6..88d0e1574e 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/AbstractDirectGrantAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/AbstractDirectGrantAuthenticator.java @@ -24,6 +24,7 @@ import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -36,12 +37,8 @@ import java.util.Map; */ public abstract class AbstractDirectGrantAuthenticator implements Authenticator, AuthenticatorFactory { public Response errorResponse(int status, String error, String errorDescription) { - Map e = new HashMap(); - e.put(OAuth2Constants.ERROR, error); - if (errorDescription != null) { - e.put(OAuth2Constants.ERROR_DESCRIPTION, errorDescription); - } - return Response.status(status).entity(e).type(MediaType.APPLICATION_JSON_TYPE).build(); + OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription); + return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build(); } @Override diff --git a/services/src/main/java/org/keycloak/services/ErrorResponseException.java b/services/src/main/java/org/keycloak/services/ErrorResponseException.java index c29f0f3e0f..538374cee6 100644 --- a/services/src/main/java/org/keycloak/services/ErrorResponseException.java +++ b/services/src/main/java/org/keycloak/services/ErrorResponseException.java @@ -18,6 +18,7 @@ package org.keycloak.services; import org.keycloak.OAuth2Constants; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.MediaType; @@ -42,12 +43,8 @@ public class ErrorResponseException extends WebApplicationException { @Override public Response getResponse() { - Map e = new HashMap(); - e.put(OAuth2Constants.ERROR, error); - if (errorDescription != null) { - e.put(OAuth2Constants.ERROR_DESCRIPTION, errorDescription); - } - return Response.status(status).entity(e).type(MediaType.APPLICATION_JSON_TYPE).build(); + OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription); + return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build(); } } diff --git a/services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java b/services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java index ffda262dd9..87fab80ad3 100755 --- a/services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java @@ -54,8 +54,9 @@ public abstract class AbstractClientRegistrationProvider implements ClientRegist client = ModelToRepresentation.toRepresentation(clientModel); - String registrationAccessToken = ClientRegistrationTokenUtils.updateRegistrationAccessToken(session, clientModel); + client.setSecret(clientModel.getSecret()); + String registrationAccessToken = ClientRegistrationTokenUtils.updateRegistrationAccessToken(session, clientModel); client.setRegistrationAccessToken(registrationAccessToken); if (auth.isInitialAccessToken()) { diff --git a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java index 5564ef282d..7886cf8864 100644 --- a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java @@ -21,8 +21,10 @@ import org.jboss.resteasy.spi.Failure; import org.jboss.resteasy.spi.NotFoundException; import org.jboss.resteasy.spi.UnauthorizedException; import org.keycloak.Config; +import org.keycloak.OAuthErrorException; import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.common.util.Time; +import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.models.AdminRoles; @@ -33,7 +35,9 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ForbiddenException; +import org.keycloak.services.ServicesLogger; import org.keycloak.util.TokenUtil; import javax.ws.rs.core.HttpHeaders; @@ -65,23 +69,24 @@ public class ClientRegistrationAuth { String authorizationHeader = session.getContext().getRequestHeaders().getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (authorizationHeader == null) { - return; + throw unauthorized("Missing Authorization header"); } String[] split = authorizationHeader.split(" "); if (!split[0].equalsIgnoreCase("bearer")) { - return; + throw unauthorized("Invalid Authorization header. Expected type: Bearer"); } - jwt = ClientRegistrationTokenUtils.verifyToken(realm, uri, split[1]); - if (jwt == null) { - throw unauthorized(); + ClientRegistrationTokenUtils.TokenVerification tokenVerification = ClientRegistrationTokenUtils.verifyToken(realm, uri, split[1]); + if (tokenVerification.getError() != null) { + throw unauthorized(tokenVerification.getError().getMessage()); } + jwt = tokenVerification.getJwt(); if (isInitialAccessToken()) { initialAccessModel = session.sessions().getClientInitialAccessModel(session.getContext().getRealm(), jwt.getId()); if (initialAccessModel == null) { - throw unauthorized(); + throw unauthorized("Initial Access Token not found"); } } } @@ -115,7 +120,7 @@ public class ClientRegistrationAuth { } } - throw unauthorized(); + throw unauthorized("Not authorized to view client. Maybe bad token type."); } public void requireView(ClientModel client) { @@ -131,18 +136,18 @@ public class ClientRegistrationAuth { throw forbidden(); } } else if (isRegistrationAccessToken()) { - if (client.getRegistrationToken() != null && client != null && client.getRegistrationToken().equals(jwt.getId())) { + if (client.getRegistrationToken() != null && client.getRegistrationToken().equals(jwt.getId())) { return; } } else if (isInitialAccessToken()) { - throw unauthorized(); + throw unauthorized("Not initial access token"); } else { if (authenticateClient(client)) { return; } } - throw unauthorized(); + throw unauthorized("Not authorized to view client. Maybe bad token type."); } public void requireUpdate(ClientModel client) { @@ -163,7 +168,7 @@ public class ClientRegistrationAuth { } } - throw unauthorized(); + throw unauthorized("Not authorized to update client. Maybe bad token type."); } public ClientInitialAccessModel getInitialAccessModel() { @@ -218,36 +223,36 @@ public class ClientRegistrationAuth { Response response = processor.authenticateClient(); if (response != null) { event.client(client.getClientId()).error(Errors.NOT_ALLOWED); - throw unauthorized(); + throw unauthorized("Failed to authenticate client"); } ClientModel authClient = processor.getClient(); - if (client == null) { + if (authClient == null) { event.client(client.getClientId()).error(Errors.NOT_ALLOWED); - throw unauthorized(); + throw unauthorized("No client authenticated"); } if (!authClient.getClientId().equals(client.getClientId())) { event.client(client.getClientId()).error(Errors.NOT_ALLOWED); - throw unauthorized(); + throw unauthorized("Different client authenticated"); } return true; } - private Failure unauthorized() { - event.error(Errors.NOT_ALLOWED); - return new UnauthorizedException(); + private Failure unauthorized(String errorDescription) { + event.detail(Details.REASON, errorDescription).error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, errorDescription, Response.Status.UNAUTHORIZED); } private Failure forbidden() { event.error(Errors.NOT_ALLOWED); - return new ForbiddenException(); + throw new ErrorResponseException(OAuthErrorException.INSUFFICIENT_SCOPE, "Forbidden", Response.Status.FORBIDDEN); } private Failure notFound() { - event.error(Errors.NOT_ALLOWED); - return new NotFoundException("Client not found"); + event.error(Errors.CLIENT_NOT_FOUND); + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Client not found", Response.Status.NOT_FOUND); } } diff --git a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationTokenUtils.java b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationTokenUtils.java index 2fe65cc8fd..2f33e5d5ce 100755 --- a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationTokenUtils.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationTokenUtils.java @@ -56,40 +56,44 @@ public class ClientRegistrationTokenUtils { return createToken(realm, uri, model.getId(), TYPE_INITIAL_ACCESS_TOKEN, model.getExpiration() > 0 ? model.getTimestamp() + model.getExpiration() : 0); } - public static JsonWebToken verifyToken(RealmModel realm, UriInfo uri, String token) { + public static TokenVerification verifyToken(RealmModel realm, UriInfo uri, String token) { + if (token == null) { + return TokenVerification.error(new RuntimeException("Missing token")); + } + JWSInput input; try { input = new JWSInput(token); } catch (JWSInputException e) { - return null; + return TokenVerification.error(new RuntimeException("Invalid token", e)); } if (!RSAProvider.verify(input, realm.getPublicKey())) { - return null; + return TokenVerification.error(new RuntimeException("Failed verify token")); } JsonWebToken jwt; try { jwt = input.readJsonContent(JsonWebToken.class); } catch (JWSInputException e) { - return null; + return TokenVerification.error(new RuntimeException("Token is not JWT", e)); } if (!getIssuer(realm, uri).equals(jwt.getIssuer())) { - return null; + return TokenVerification.error(new RuntimeException("Issuer from token don't match with the realm issuer.")); } if (!jwt.isActive()) { - return null; + return TokenVerification.error(new RuntimeException("Token not active.")); } if (!(TokenUtil.TOKEN_TYPE_BEARER.equals(jwt.getType()) || TYPE_INITIAL_ACCESS_TOKEN.equals(jwt.getType()) || TYPE_REGISTRATION_ACCESS_TOKEN.equals(jwt.getType()))) { - return null; + return TokenVerification.error(new RuntimeException("Invalid type of token")); } - return jwt; + return TokenVerification.success(jwt); } private static String createToken(RealmModel realm, UriInfo uri, String id, String type, int expiration) { @@ -112,4 +116,31 @@ public class ClientRegistrationTokenUtils { return Urls.realmIssuer(uri.getBaseUri(), realm.getName()); } + protected static class TokenVerification { + + private final JsonWebToken jwt; + private final RuntimeException error; + + public static TokenVerification success(JsonWebToken jwt) { + return new TokenVerification(jwt, null); + } + + public static TokenVerification error(RuntimeException error) { + return new TokenVerification(null, error); + } + + private TokenVerification(JsonWebToken jwt, RuntimeException error) { + this.jwt = jwt; + this.error = error; + } + + public JsonWebToken getJwt() { + return jwt; + } + + public RuntimeException getError() { + return error; + } + } + } diff --git a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationUriUtils.java b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationUriUtils.java new file mode 100644 index 0000000000..31080034a9 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationUriUtils.java @@ -0,0 +1,7 @@ +package org.keycloak.services.clientregistration; + +/** + * @author Marek Posolda + */ +public class ClientRegistrationUriUtils { +} diff --git a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java index cfbf42d74f..b797282be7 100644 --- a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java @@ -66,6 +66,8 @@ public class DescriptionConverter { public static OIDCClientRepresentation toExternalResponse(ClientRepresentation client, URI uri) { OIDCClientRepresentation response = new OIDCClientRepresentation(); response.setClientId(client.getClientId()); + response.setClientSecret(client.getSecret()); + response.setClientSecretExpiresAt(0); response.setClientName(client.getName()); response.setClientUri(client.getBaseUrl()); response.setClientSecret(client.getSecret()); diff --git a/services/src/main/java/org/keycloak/services/resources/ClientsManagementService.java b/services/src/main/java/org/keycloak/services/resources/ClientsManagementService.java index c6335871ee..898ad3c05a 100755 --- a/services/src/main/java/org/keycloak/services/resources/ClientsManagementService.java +++ b/services/src/main/java/org/keycloak/services/resources/ClientsManagementService.java @@ -19,6 +19,7 @@ package org.keycloak.services.resources; import org.jboss.resteasy.spi.BadRequestException; import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.UnauthorizedException; +import org.keycloak.OAuthErrorException; import org.keycloak.common.ClientConnection; import org.keycloak.OAuth2Constants; import org.keycloak.constants.AdapterConstants; @@ -30,6 +31,7 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.services.ForbiddenException; import org.keycloak.services.ServicesLogger; import org.keycloak.common.util.Time; @@ -172,11 +174,9 @@ public class ClientsManagementService { ClientModel client = AuthorizeClientUtil.authorizeClient(session, event).getClient(); if (client.isPublicClient()) { - Map error = new HashMap(); - error.put(OAuth2Constants.ERROR, "invalid_client"); - error.put(OAuth2Constants.ERROR_DESCRIPTION, "Public clients not allowed"); + OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(OAuthErrorException.INVALID_CLIENT, "Public clients not allowed"); event.error(Errors.INVALID_CLIENT); - throw new BadRequestException("Public clients not allowed", javax.ws.rs.core.Response.status(javax.ws.rs.core.Response.Status.BAD_REQUEST).entity(error).type(MediaType.APPLICATION_JSON_TYPE).build()); + throw new BadRequestException("Public clients not allowed", javax.ws.rs.core.Response.status(javax.ws.rs.core.Response.Status.BAD_REQUEST).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build()); } return client; @@ -185,11 +185,9 @@ public class ClientsManagementService { protected String getClientClusterHost(MultivaluedMap formData) { String clientClusterHost = formData.getFirst(AdapterConstants.CLIENT_CLUSTER_HOST); if (clientClusterHost == null || clientClusterHost.length() == 0) { - Map error = new HashMap(); - error.put(OAuth2Constants.ERROR, "invalid_request"); - error.put(OAuth2Constants.ERROR_DESCRIPTION, "Client cluster host not specified"); + OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation( OAuthErrorException.INVALID_REQUEST, "Client cluster host not specified"); event.error(Errors.INVALID_CODE); - throw new BadRequestException("Cluster host not specified", javax.ws.rs.core.Response.status(javax.ws.rs.core.Response.Status.BAD_REQUEST).entity(error).type(MediaType.APPLICATION_JSON_TYPE).build()); + throw new BadRequestException("Cluster host not specified", javax.ws.rs.core.Response.status(javax.ws.rs.core.Response.Status.BAD_REQUEST).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build()); } return clientClusterHost; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java index ab587c3cdb..9b364e8d06 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java @@ -20,13 +20,17 @@ package org.keycloak.testsuite.client; import org.junit.Before; import org.junit.Test; import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; import org.keycloak.client.registration.Auth; import org.keycloak.client.registration.ClientRegistrationException; +import org.keycloak.client.registration.HttpErrorException; import org.keycloak.common.util.CollectionUtil; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation; import org.keycloak.representations.idm.ClientInitialAccessPresentation; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.testsuite.Assert; import java.util.Arrays; import java.util.Collections; @@ -57,6 +61,25 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest { return response; } + @Test + public void testMissingToken() throws Exception { + reg.auth(null); + + OIDCClientRepresentation client = new OIDCClientRepresentation(); + client.setClientName("RegistrationAccessTokenTest"); + client.setClientUri("http://root"); + client.setRedirectUris(Collections.singletonList("http://redirect")); + + try { + reg.oidc().create(client); + Assert.fail("Not expected to successfuly register client"); + } catch (ClientRegistrationException expected) { + HttpErrorException httpEx = (HttpErrorException) expected.getCause(); + Assert.assertEquals(401, httpEx.getStatusLine().getStatusCode()); + Assert.assertEquals(OAuthErrorException.INVALID_TOKEN, httpEx.toErrorRepresentation().getError()); + } + } + @Test public void createClient() throws ClientRegistrationException { OIDCClientRepresentation response = create(); @@ -64,7 +87,8 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest { assertNotNull(response.getRegistrationAccessToken()); assertNotNull(response.getClientIdIssuedAt()); assertNotNull(response.getClientId()); - assertNull(response.getClientSecretExpiresAt()); + assertNotNull(response.getClientSecret()); + assertEquals(0, response.getClientSecretExpiresAt().intValue()); assertNotNull(response.getRegistrationClientUri()); assertEquals("RegistrationAccessTokenTest", response.getClientName()); assertEquals("http://root", response.getClientUri()); @@ -84,6 +108,8 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest { assertNotEquals(response.getRegistrationAccessToken(), rep.getRegistrationAccessToken()); assertTrue(CollectionUtil.collectionEquals(Arrays.asList("code", "none"), response.getResponseTypes())); assertTrue(CollectionUtil.collectionEquals(Arrays.asList(OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.REFRESH_TOKEN), response.getGrantTypes())); + assertNotNull(response.getClientSecret()); + assertEquals(0, response.getClientSecretExpiresAt().intValue()); } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java index 900767fa86..ec800edffc 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java @@ -21,12 +21,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.oidc.TokenMetadataRepresentation; +import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.TestRealmKeycloakTest; import org.keycloak.testsuite.util.KeycloakModelUtils; @@ -112,7 +115,9 @@ public class TokenIntrospectionTest extends TestRealmKeycloakTest { AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(code, "password"); String tokenResponse = oauth.introspectAccessTokenWithClientCredential("confidential-cli", "bad_credential", accessTokenResponse.getAccessToken()); - assertEquals("{\"error_description\":\"Authentication failed.\",\"error\":\"invalid_request\"}", tokenResponse); + OAuth2ErrorRepresentation errorRep = JsonSerialization.readValue(tokenResponse, OAuth2ErrorRepresentation.class); + Assert.assertEquals("Authentication failed.", errorRep.getErrorDescription()); + Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, errorRep.getError()); } @Test @@ -157,7 +162,9 @@ public class TokenIntrospectionTest extends TestRealmKeycloakTest { AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(code, "password"); String tokenResponse = oauth.introspectAccessTokenWithClientCredential("public-cli", "it_doesnt_matter", accessTokenResponse.getAccessToken()); - assertEquals("{\"error_description\":\"Client not allowed.\",\"error\":\"invalid_request\"}", tokenResponse); + OAuth2ErrorRepresentation errorRep = JsonSerialization.readValue(tokenResponse, OAuth2ErrorRepresentation.class); + Assert.assertEquals("Client not allowed.", errorRep.getErrorDescription()); + Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, errorRep.getError()); } @Test From 0520d465c135553b221ad5736f7a27480868c8fe Mon Sep 17 00:00:00 2001 From: mposolda Date: Tue, 9 Aug 2016 18:28:44 +0200 Subject: [PATCH 2/2] KEYCLOAK-3414 Support for client registration from trusted hosts --- ...RegistrationTrustedHostRepresentation.java | 60 ++++++ ...ClientRegistrationTrustedHostResource.java | 64 ++++++ .../admin/client/resource/RealmResource.java | 3 + misc/OIDCConformanceTestsuite.md | 17 +- .../models/cache/infinispan/RealmAdapter.java | 1 + .../ClientRegistrationTrustedHostAdapter.java | 87 +++++++++ .../InfinispanUserSessionProvider.java | 66 +++++++ .../ClientRegistrationTrustedHostEntity.java | 54 ++++++ ...lientRegistrationTrustedHostPredicate.java | 58 ++++++ .../org/keycloak/models/jpa/RealmAdapter.java | 1 + .../META-INF/db2-jpa-changelog-master.xml | 1 + .../mongo/keycloak/adapters/RealmAdapter.java | 1 + .../keycloak/events/admin/ResourceType.java | 5 + .../ClientRegistrationTrustedHostModel.java | 36 ++++ .../keycloak/models/UserSessionProvider.java | 5 + .../AbstractClientRegistrationProvider.java | 6 + .../ClientRegistrationAuth.java | 33 +++- .../ClientRegistrationHostUtils.java | 65 +++++++ .../ClientRegistrationUriUtils.java | 7 - ...ClientRegistrationTrustedHostResource.java | 183 ++++++++++++++++++ .../resources/admin/RealmAdminResource.java | 13 ++ .../admin/ClientRegTrustedHostTest.java | 113 +++++++++++ .../client/OIDCClientRegistrationTest.java | 44 ++++- .../testsuite/util/AdminEventPaths.java | 9 + .../messages/admin-messages_en.properties | 13 ++ .../theme/base/admin/resources/js/app.js | 27 +++ .../admin/resources/js/controllers/realm.js | 67 ++++++- .../theme/base/admin/resources/js/loaders.js | 17 ++ .../theme/base/admin/resources/js/services.js | 12 ++ .../partials/client-initial-access.html | 65 ++++++- .../client-reg-trusted-host-create.html | 55 ++++++ .../client-reg-trusted-host-detail.html | 64 ++++++ .../admin/resources/templates/kc-menu.html | 1 + 33 files changed, 1219 insertions(+), 34 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/idm/ClientRegistrationTrustedHostRepresentation.java create mode 100644 integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientRegistrationTrustedHostResource.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientRegistrationTrustedHostAdapter.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ClientRegistrationTrustedHostEntity.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/ClientRegistrationTrustedHostPredicate.java create mode 100644 server-spi/src/main/java/org/keycloak/models/ClientRegistrationTrustedHostModel.java create mode 100644 services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationHostUtils.java delete mode 100644 services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationUriUtils.java create mode 100644 services/src/main/java/org/keycloak/services/resources/admin/ClientRegistrationTrustedHostResource.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientRegTrustedHostTest.java create mode 100644 themes/src/main/resources/theme/base/admin/resources/partials/client-reg-trusted-host-create.html create mode 100644 themes/src/main/resources/theme/base/admin/resources/partials/client-reg-trusted-host-detail.html diff --git a/core/src/main/java/org/keycloak/representations/idm/ClientRegistrationTrustedHostRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/ClientRegistrationTrustedHostRepresentation.java new file mode 100644 index 0000000000..1df7ab44b2 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/ClientRegistrationTrustedHostRepresentation.java @@ -0,0 +1,60 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.representations.idm; + +/** + * @author Marek Posolda + */ +public class ClientRegistrationTrustedHostRepresentation { + + String hostName; + Integer count; + Integer remainingCount; + + public static ClientRegistrationTrustedHostRepresentation create(String hostName, int count, int remainingCount) { + ClientRegistrationTrustedHostRepresentation rep = new ClientRegistrationTrustedHostRepresentation(); + rep.setHostName(hostName); + rep.setCount(count); + rep.setRemainingCount(remainingCount); + return rep; + } + + public String getHostName() { + return hostName; + } + + public void setHostName(String hostName) { + this.hostName = hostName; + } + + public Integer getCount() { + return count; + } + + public void setCount(Integer count) { + this.count = count; + } + + public Integer getRemainingCount() { + return remainingCount; + } + + public void setRemainingCount(Integer remainingCount) { + this.remainingCount = remainingCount; + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientRegistrationTrustedHostResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientRegistrationTrustedHostResource.java new file mode 100644 index 0000000000..4f5ace9c4d --- /dev/null +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientRegistrationTrustedHostResource.java @@ -0,0 +1,64 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.admin.client.resource; + +import java.util.List; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import org.keycloak.representations.idm.ClientRegistrationTrustedHostRepresentation; + +/** + * @author Marek Posolda + */ +public interface ClientRegistrationTrustedHostResource { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + Response create(ClientRegistrationTrustedHostRepresentation config); + + @PUT + @Path("{hostname}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + Response update(final @PathParam("hostname") String hostName, ClientRegistrationTrustedHostRepresentation config); + + @GET + @Path("{hostname}") + @Produces(MediaType.APPLICATION_JSON) + ClientRegistrationTrustedHostRepresentation get(final @PathParam("hostname") String hostName); + + @GET + @Produces(MediaType.APPLICATION_JSON) + List list(); + + @DELETE + @Path("{hostname}") + void delete(final @PathParam("hostname") String hostName); + +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/RealmResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/RealmResource.java index eb25bfb43d..ba91227448 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/RealmResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/RealmResource.java @@ -148,6 +148,9 @@ public interface RealmResource { @Path("clients-initial-access") ClientInitialAccessResource clientInitialAccess(); + @Path("clients-trusted-hosts") + public ClientRegistrationTrustedHostResource clientRegistrationTrustedHost(); + @Path("partialImport") @POST @Consumes(MediaType.APPLICATION_JSON) diff --git a/misc/OIDCConformanceTestsuite.md b/misc/OIDCConformanceTestsuite.md index 9e4650adca..72ec17b999 100644 --- a/misc/OIDCConformanceTestsuite.md +++ b/misc/OIDCConformanceTestsuite.md @@ -34,7 +34,7 @@ Q: Does the OP have a .well-known/openid-configuration endpoint? A: Yes Q: Do the provider support dynamic client registration? -A: No (just for easier start) +A: No (See below for how to run with dynamic client registration) Q: redirect_uris Non-editable value: https://op.certification.openid.net:60720/authz_cb @@ -62,6 +62,21 @@ Nothing filled 4) After setup, you will be redirected to the testing application. Something like `https://op.certification.openid.net:60720/` and can run individual tests. Some tests require some manual actions (eg. delete cookies). The conformance testsuite should guide you. +Run conformance testsuite with Dynamic client registration +---------------------------------------------------------- +1) The steps are similar to above, however for question: + +Q: Do the provider support dynamic client registration? +The answer will be: Yes + +Then you don't need to configure redirect_uris, client_id and client_secret. + +2) With the setup from previous point, OIDC Conformance testsuite will dynamically register new client in Keycloak. But you also need to allow the anonymous + client registration requests from the OIDC conformance to register clients. + + So you need to login to Keycloak admin console and in tab "Initial Access Tokens" for realm master, you need to fill new trusted host. Fill the hostname "op.certification.openid.net" and enable big + count of registrations for it (1000 or so) as running each test will register new client. + Update the openshift cartridge with latest Keycloak --------------------------------------------------- diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java index 50e1b696db..8256aa221d 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java @@ -19,6 +19,7 @@ package org.keycloak.models.cache.infinispan; import org.keycloak.Config; import org.keycloak.common.enums.SslRequired; +import org.keycloak.common.util.StringPropertyReplacer; import org.keycloak.component.ComponentModel; import org.keycloak.models.*; import org.keycloak.models.cache.infinispan.entities.CachedRealm; diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientRegistrationTrustedHostAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientRegistrationTrustedHostAdapter.java new file mode 100644 index 0000000000..34e68cc9f1 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientRegistrationTrustedHostAdapter.java @@ -0,0 +1,87 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.sessions.infinispan; + +import org.infinispan.Cache; +import org.keycloak.models.ClientRegistrationTrustedHostModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.sessions.infinispan.entities.ClientRegistrationTrustedHostEntity; +import org.keycloak.models.sessions.infinispan.entities.SessionEntity; + +/** + * @author Marek Posolda + */ +public class ClientRegistrationTrustedHostAdapter implements ClientRegistrationTrustedHostModel { + + private final KeycloakSession session; + private final InfinispanUserSessionProvider provider; + private final Cache cache; + private final RealmModel realm; + private final ClientRegistrationTrustedHostEntity entity; + + public ClientRegistrationTrustedHostAdapter(KeycloakSession session, InfinispanUserSessionProvider provider, Cache cache, RealmModel realm, ClientRegistrationTrustedHostEntity entity) { + this.session = session; + this.provider = provider; + this.cache = cache; + this.realm = realm; + this.entity = entity; + } + + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public String getHostName() { + return entity.getHostName(); + } + + @Override + public int getCount() { + return entity.getCount(); + } + + @Override + public void setCount(int count) { + entity.setCount(count); + update(); + } + + @Override + public int getRemainingCount() { + return entity.getRemainingCount(); + } + + @Override + public void setRemainingCount(int remainingCount) { + entity.setRemainingCount(remainingCount); + update(); + } + + @Override + public void decreaseRemainingCount() { + entity.setRemainingCount(entity.getRemainingCount() - 1); + update(); + } + + void update() { + provider.getTx().replace(cache, entity.getId(), entity); + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java index 614fa9fa9d..54528690ed 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java @@ -23,9 +23,11 @@ import org.jboss.logging.Logger; import org.keycloak.common.util.Time; import org.keycloak.models.ClientInitialAccessModel; import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientRegistrationTrustedHostModel; import org.keycloak.models.ClientSessionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakTransaction; +import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; @@ -33,12 +35,14 @@ import org.keycloak.models.UserSessionProvider; import org.keycloak.models.UserLoginFailureModel; import org.keycloak.models.session.UserSessionPersisterProvider; import org.keycloak.models.sessions.infinispan.entities.ClientInitialAccessEntity; +import org.keycloak.models.sessions.infinispan.entities.ClientRegistrationTrustedHostEntity; import org.keycloak.models.sessions.infinispan.entities.ClientSessionEntity; import org.keycloak.models.sessions.infinispan.entities.LoginFailureEntity; import org.keycloak.models.sessions.infinispan.entities.LoginFailureKey; import org.keycloak.models.sessions.infinispan.entities.SessionEntity; import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; import org.keycloak.models.sessions.infinispan.stream.ClientInitialAccessPredicate; +import org.keycloak.models.sessions.infinispan.stream.ClientRegistrationTrustedHostPredicate; import org.keycloak.models.sessions.infinispan.stream.ClientSessionPredicate; import org.keycloak.models.sessions.infinispan.stream.Comparators; import org.keycloak.models.sessions.infinispan.stream.Mappers; @@ -537,6 +541,11 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { return entity != null ? new ClientInitialAccessAdapter(session, this, cache, realm, entity) : null; } + ClientRegistrationTrustedHostAdapter wrap(RealmModel realm, ClientRegistrationTrustedHostEntity entity) { + Cache cache = getCache(false); + return entity != null ? new ClientRegistrationTrustedHostAdapter(session, this, cache, realm, entity) : null; + } + UserLoginFailureModel wrap(LoginFailureKey key, LoginFailureEntity entity) { return entity != null ? new UserLoginFailureAdapter(this, loginFailureCache, key, entity) : null; @@ -729,6 +738,63 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { return list; } + @Override + public ClientRegistrationTrustedHostModel createClientRegistrationTrustedHostModel(RealmModel realm, String hostName, int count) { + if (getClientRegistrationTrustedHostModel(realm, hostName) != null) { + throw new ModelDuplicateException("Client registration already exists for this realm and hostName"); + } + + String id = computeClientRegistrationTrustedHostEntityId(realm, hostName); + + ClientRegistrationTrustedHostEntity entity = new ClientRegistrationTrustedHostEntity(); + entity.setId(id); + entity.setHostName(hostName); + entity.setRealm(realm.getId()); + entity.setCount(count); + entity.setRemainingCount(count); + + tx.put(sessionCache, id, entity); + + return wrap(realm, entity); + } + + @Override + public ClientRegistrationTrustedHostModel getClientRegistrationTrustedHostModel(RealmModel realm, String hostName) { + String id = computeClientRegistrationTrustedHostEntityId(realm, hostName); + + Cache cache = getCache(false); + ClientRegistrationTrustedHostEntity entity = (ClientRegistrationTrustedHostEntity) cache.get(id); + + // If created in this transaction + if (entity == null) { + entity = (ClientRegistrationTrustedHostEntity) tx.get(cache, id); + } + + return wrap(realm, entity); + } + + @Override + public void removeClientRegistrationTrustedHostModel(RealmModel realm, String hostName) { + String id = computeClientRegistrationTrustedHostEntityId(realm, hostName); + tx.remove(getCache(false), id); + } + + @Override + public List listClientRegistrationTrustedHosts(RealmModel realm) { + Iterator> itr = sessionCache.entrySet().stream().filter(ClientRegistrationTrustedHostPredicate.create(realm.getId())).iterator(); + List list = new LinkedList<>(); + while (itr.hasNext()) { + list.add(wrap(realm, (ClientRegistrationTrustedHostEntity) itr.next().getValue())); + } + return list; + } + + private static final String CLIENT_REG_TRUSTED_HOST_ID_PREFIX = "reg:::"; + + private String computeClientRegistrationTrustedHostEntityId(RealmModel realm, String hostName) { + return CLIENT_REG_TRUSTED_HOST_ID_PREFIX + realm.getId() + ":::" + hostName; + } + class InfinispanKeycloakTransaction implements KeycloakTransaction { private boolean active; diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ClientRegistrationTrustedHostEntity.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ClientRegistrationTrustedHostEntity.java new file mode 100644 index 0000000000..53b1ec868c --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ClientRegistrationTrustedHostEntity.java @@ -0,0 +1,54 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.sessions.infinispan.entities; + +/** + * @author Marek Posolda + */ +public class ClientRegistrationTrustedHostEntity extends SessionEntity { + + private String hostName; + + private int count; + + private int remainingCount; + + public String getHostName() { + return hostName; + } + + public void setHostName(String hostName) { + this.hostName = hostName; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public int getRemainingCount() { + return remainingCount; + } + + public void setRemainingCount(int remainingCount) { + this.remainingCount = remainingCount; + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/ClientRegistrationTrustedHostPredicate.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/ClientRegistrationTrustedHostPredicate.java new file mode 100644 index 0000000000..8663c4efaf --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/ClientRegistrationTrustedHostPredicate.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.sessions.infinispan.stream; + +import java.io.Serializable; +import java.util.Map; +import java.util.function.Predicate; + +import org.keycloak.models.sessions.infinispan.entities.ClientRegistrationTrustedHostEntity; +import org.keycloak.models.sessions.infinispan.entities.SessionEntity; + +/** + * @author Marek Posolda + */ +public class ClientRegistrationTrustedHostPredicate implements Predicate>, Serializable { + + public static ClientRegistrationTrustedHostPredicate create(String realm) { + return new ClientRegistrationTrustedHostPredicate(realm); + } + + private ClientRegistrationTrustedHostPredicate(String realm) { + this.realm = realm; + } + + private String realm; + + + @Override + public boolean test(Map.Entry entry) { + SessionEntity e = entry.getValue(); + + if (!realm.equals(e.getRealm())) { + return false; + } + + if (!(e instanceof ClientRegistrationTrustedHostEntity)) { + return false; + } + + return true; + } + +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java index 68aa02c254..46df41a455 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java @@ -19,6 +19,7 @@ package org.keycloak.models.jpa; import org.jboss.logging.Logger; import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.StringPropertyReplacer; import org.keycloak.component.ComponentModel; import org.keycloak.common.enums.SslRequired; import org.keycloak.jose.jwk.JWKBuilder; diff --git a/model/jpa/src/main/resources/META-INF/db2-jpa-changelog-master.xml b/model/jpa/src/main/resources/META-INF/db2-jpa-changelog-master.xml index 1481095bb6..e6d2753624 100644 --- a/model/jpa/src/main/resources/META-INF/db2-jpa-changelog-master.xml +++ b/model/jpa/src/main/resources/META-INF/db2-jpa-changelog-master.xml @@ -35,4 +35,5 @@ + diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java index 701cfb0b02..e7dc5f60ce 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java @@ -21,6 +21,7 @@ import com.mongodb.DBObject; import com.mongodb.QueryBuilder; import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.StringPropertyReplacer; import org.keycloak.component.ComponentModel; import org.keycloak.connections.mongo.api.context.MongoStoreInvocationContext; import org.keycloak.common.enums.SslRequired; diff --git a/server-spi/src/main/java/org/keycloak/events/admin/ResourceType.java b/server-spi/src/main/java/org/keycloak/events/admin/ResourceType.java index 045258c08b..8b9c27c1ad 100644 --- a/server-spi/src/main/java/org/keycloak/events/admin/ResourceType.java +++ b/server-spi/src/main/java/org/keycloak/events/admin/ResourceType.java @@ -128,6 +128,11 @@ public enum ResourceType { */ , CLIENT_INITIAL_ACCESS_MODEL + /** + * + */ + , CLIENT_REGISTRATION_TRUSTED_HOST_MODEL + /** * */ diff --git a/server-spi/src/main/java/org/keycloak/models/ClientRegistrationTrustedHostModel.java b/server-spi/src/main/java/org/keycloak/models/ClientRegistrationTrustedHostModel.java new file mode 100644 index 0000000000..30b2e6fa2e --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/ClientRegistrationTrustedHostModel.java @@ -0,0 +1,36 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models; + +/** + * @author Marek Posolda + */ +public interface ClientRegistrationTrustedHostModel { + + RealmModel getRealm(); + + String getHostName(); + + int getCount(); + void setCount(int count); + + int getRemainingCount(); + void setRemainingCount(int remainingCount); + void decreaseRemainingCount(); + +} diff --git a/server-spi/src/main/java/org/keycloak/models/UserSessionProvider.java b/server-spi/src/main/java/org/keycloak/models/UserSessionProvider.java index 585558c102..27ce108734 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserSessionProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/UserSessionProvider.java @@ -82,6 +82,11 @@ public interface UserSessionProvider extends Provider { void removeClientInitialAccessModel(RealmModel realm, String id); List listClientInitialAccess(RealmModel realm); + ClientRegistrationTrustedHostModel createClientRegistrationTrustedHostModel(RealmModel realm, String hostName, int count); + ClientRegistrationTrustedHostModel getClientRegistrationTrustedHostModel(RealmModel realm, String hostName); + void removeClientRegistrationTrustedHostModel(RealmModel realm, String hostName); + List listClientRegistrationTrustedHosts(RealmModel realm); + void close(); } diff --git a/services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java b/services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java index 87fab80ad3..88b5a340c1 100755 --- a/services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/AbstractClientRegistrationProvider.java @@ -21,6 +21,7 @@ import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; import org.keycloak.models.ClientInitialAccessModel; import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientRegistrationTrustedHostModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.utils.ModelToRepresentation; @@ -64,6 +65,11 @@ public abstract class AbstractClientRegistrationProvider implements ClientRegist initialAccessModel.decreaseRemainingCount(); } + if (auth.isRegistrationHostTrusted()) { + ClientRegistrationTrustedHostModel trustedHost = auth.getTrustedHostModel(); + trustedHost.decreaseRemainingCount(); + } + event.client(client.getClientId()).success(); return client; } catch (ModelDuplicateException e) { diff --git a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java index 7886cf8864..93c264825a 100644 --- a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java @@ -18,8 +18,6 @@ package org.keycloak.services.clientregistration; import org.jboss.resteasy.spi.Failure; -import org.jboss.resteasy.spi.NotFoundException; -import org.jboss.resteasy.spi.UnauthorizedException; import org.keycloak.Config; import org.keycloak.OAuthErrorException; import org.keycloak.authentication.AuthenticationProcessor; @@ -30,14 +28,13 @@ import org.keycloak.events.EventBuilder; import org.keycloak.models.AdminRoles; import org.keycloak.models.ClientInitialAccessModel; import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientRegistrationTrustedHostModel; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ErrorResponseException; -import org.keycloak.services.ForbiddenException; -import org.keycloak.services.ServicesLogger; import org.keycloak.util.TokenUtil; import javax.ws.rs.core.HttpHeaders; @@ -58,6 +55,8 @@ public class ClientRegistrationAuth { private JsonWebToken jwt; private ClientInitialAccessModel initialAccessModel; + private ClientRegistrationTrustedHostModel trustedHostModel; + public ClientRegistrationAuth(KeycloakSession session, EventBuilder event) { this.session = session; this.event = event; @@ -69,12 +68,15 @@ public class ClientRegistrationAuth { String authorizationHeader = session.getContext().getRequestHeaders().getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (authorizationHeader == null) { - throw unauthorized("Missing Authorization header"); + + // Try trusted hosts + trustedHostModel = ClientRegistrationHostUtils.getTrustedHost(session.getContext().getConnection().getRemoteAddr(), session, realm); + return; } String[] split = authorizationHeader.split(" "); if (!split[0].equalsIgnoreCase("bearer")) { - throw unauthorized("Invalid Authorization header. Expected type: Bearer"); + return; } ClientRegistrationTokenUtils.TokenVerification tokenVerification = ClientRegistrationTokenUtils.verifyToken(realm, uri, split[1]); @@ -91,6 +93,10 @@ public class ClientRegistrationAuth { } } + public boolean isRegistrationHostTrusted() { + return trustedHostModel != null; + } + private boolean isBearerToken() { return jwt != null && TokenUtil.TOKEN_TYPE_BEARER.equals(jwt.getType()); } @@ -106,7 +112,10 @@ public class ClientRegistrationAuth { public void requireCreate() { init(); - if (isBearerToken()) { + if (isRegistrationHostTrusted()) { + // Client registrations from trusted hosts + return; + } else if (isBearerToken()) { if (hasRole(AdminRoles.MANAGE_CLIENTS, AdminRoles.CREATE_CLIENT)) { return; } else { @@ -120,7 +129,7 @@ public class ClientRegistrationAuth { } } - throw unauthorized("Not authorized to view client. Maybe bad token type."); + throw unauthorized("Not authenticated to view client. Host not trusted and Token is missing or invalid."); } public void requireView(ClientModel client) { @@ -147,7 +156,7 @@ public class ClientRegistrationAuth { } } - throw unauthorized("Not authorized to view client. Maybe bad token type."); + throw unauthorized("Not authorized to view client. Missing or invalid token or bad client credentials."); } public void requireUpdate(ClientModel client) { @@ -168,13 +177,17 @@ public class ClientRegistrationAuth { } } - throw unauthorized("Not authorized to update client. Maybe bad token type."); + throw unauthorized("Not authorized to update client. Missing or invalid token."); } public ClientInitialAccessModel getInitialAccessModel() { return initialAccessModel; } + public ClientRegistrationTrustedHostModel getTrustedHostModel() { + return trustedHostModel; + } + private boolean hasRole(String... role) { try { Map otherClaims = jwt.getOtherClaims(); diff --git a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationHostUtils.java b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationHostUtils.java new file mode 100644 index 0000000000..93cd98400a --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationHostUtils.java @@ -0,0 +1,65 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.services.clientregistration; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; + +import org.jboss.logging.Logger; +import org.keycloak.models.ClientRegistrationTrustedHostModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; + +/** + * @author Marek Posolda + */ +public class ClientRegistrationHostUtils { + + private static final Logger logger = Logger.getLogger(ClientRegistrationHostUtils.class); + + /** + * @return null if host from request is not trusted. Otherwise return trusted host model + */ + public static ClientRegistrationTrustedHostModel getTrustedHost(String hostAddress, KeycloakSession session, RealmModel realm) { + logger.debugf("Verifying remote host : %s", hostAddress); + + List trustedHosts = session.sessions().listClientRegistrationTrustedHosts(realm); + + for (ClientRegistrationTrustedHostModel realmTrustedHost : trustedHosts) { + try { + if (realmTrustedHost.getRemainingCount() <= 0) { + continue; + } + + String realmHostIPAddress = InetAddress.getByName(realmTrustedHost.getHostName()).getHostAddress(); + logger.debugf("Trying host '%s' of address '%s'", realmTrustedHost.getHostName(), realmHostIPAddress); + if (realmHostIPAddress.equals(hostAddress)) { + logger.debugf("Successfully verified host : %s", realmTrustedHost.getHostName()); + return realmTrustedHost; + } + } catch (UnknownHostException uhe) { + logger.debugf("Unknown host from realm configuration: %s", realmTrustedHost.getHostName()); + } + } + + logger.debugf("Failed to verify remote host : %s", hostAddress); + return null; + } + +} diff --git a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationUriUtils.java b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationUriUtils.java deleted file mode 100644 index 31080034a9..0000000000 --- a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationUriUtils.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.keycloak.services.clientregistration; - -/** - * @author Marek Posolda - */ -public class ClientRegistrationUriUtils { -} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientRegistrationTrustedHostResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientRegistrationTrustedHostResource.java new file mode 100644 index 0000000000..000885fd57 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientRegistrationTrustedHostResource.java @@ -0,0 +1,183 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.services.resources.admin; + +import java.util.LinkedList; +import java.util.List; + +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +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 javax.ws.rs.core.UriInfo; + +import org.jboss.resteasy.spi.NotFoundException; +import org.keycloak.events.admin.OperationType; +import org.keycloak.events.admin.ResourceType; +import org.keycloak.models.ClientRegistrationTrustedHostModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.RealmModel; +import org.keycloak.representations.idm.ClientRegistrationTrustedHostRepresentation; +import org.keycloak.services.ErrorResponse; + +/** + * @author Marek Posolda + */ +public class ClientRegistrationTrustedHostResource { + + private final RealmAuth auth; + private final RealmModel realm; + private final AdminEventBuilder adminEvent; + + @Context + protected KeycloakSession session; + + @Context + protected UriInfo uriInfo; + + public ClientRegistrationTrustedHostResource(RealmModel realm, RealmAuth auth, AdminEventBuilder adminEvent) { + this.auth = auth; + this.realm = realm; + this.adminEvent = adminEvent.resource(ResourceType.CLIENT_REGISTRATION_TRUSTED_HOST_MODEL); + + auth.init(RealmAuth.Resource.CLIENT); + } + + /** + * Create a new initial access token. + * + * @param config + * @return + */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response create(ClientRegistrationTrustedHostRepresentation config) { + auth.requireManage(); + + if (config.getHostName() == null) { + return ErrorResponse.error("hostName not provided in config", Response.Status.BAD_REQUEST); + } + + int count = config.getCount() != null ? config.getCount() : 1; + + try { + ClientRegistrationTrustedHostModel clientRegTrustedHostModel = session.sessions().createClientRegistrationTrustedHostModel(realm, config.getHostName(), count); + + adminEvent.operation(OperationType.CREATE).resourcePath(uriInfo, clientRegTrustedHostModel.getHostName()).representation(config).success(); + + return Response.created(uriInfo.getAbsolutePathBuilder().path(clientRegTrustedHostModel.getHostName()).build()).build(); + } catch (ModelDuplicateException mde) { + return ErrorResponse.exists(mde.getMessage()); + } + } + + /** + * Update a new initial access token. + * + * @param config + * @return + */ + @PUT + @Path("{hostname}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response update(final @PathParam("hostname") String hostName, ClientRegistrationTrustedHostRepresentation config) { + auth.requireManage(); + + if (config.getHostName() == null || !hostName.equals(config.getHostName())) { + return ErrorResponse.error("hostName not provided in config or not compatible", Response.Status.BAD_REQUEST); + } + + if (config.getCount() == null) { + return ErrorResponse.error("count needs to be available", Response.Status.BAD_REQUEST); + } + + if (config.getRemainingCount() != null && config.getRemainingCount() > config.getCount()) { + return ErrorResponse.error("remainingCount can't be bigger than count", Response.Status.BAD_REQUEST); + } + + ClientRegistrationTrustedHostModel hostModel = session.sessions().getClientRegistrationTrustedHostModel(realm, config.getHostName()); + if (hostModel == null) { + return ErrorResponse.error("hostName record not found", Response.Status.NOT_FOUND); + } + + hostModel.setCount(config.getCount()); + hostModel.setRemainingCount(config.getRemainingCount()); + + adminEvent.operation(OperationType.UPDATE).resourcePath(uriInfo).representation(config).success(); + return Response.noContent().build(); + } + + /** + * Get an initial access token. + * + * @param hostName + * @return + */ + @GET + @Path("{hostname}") + @Produces(MediaType.APPLICATION_JSON) + public ClientRegistrationTrustedHostRepresentation getConfig(final @PathParam("hostname") String hostName) { + auth.requireView(); + + ClientRegistrationTrustedHostModel hostModel = session.sessions().getClientRegistrationTrustedHostModel(realm, hostName); + if (hostModel == null) { + throw new NotFoundException("hostName record not found"); + } + + return wrap(hostModel); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public List list() { + auth.requireView(); + + List models = session.sessions().listClientRegistrationTrustedHosts(realm); + List reps = new LinkedList<>(); + for (ClientRegistrationTrustedHostModel m : models) { + ClientRegistrationTrustedHostRepresentation r = wrap(m); + reps.add(r); + } + return reps; + } + + @DELETE + @Path("{hostname}") + public void delete(final @PathParam("hostname") String hostName) { + auth.requireManage(); + + session.sessions().removeClientRegistrationTrustedHostModel(realm, hostName); + adminEvent.operation(OperationType.DELETE).resourcePath(uriInfo).success(); + } + + private ClientRegistrationTrustedHostRepresentation wrap(ClientRegistrationTrustedHostModel model) { + return ClientRegistrationTrustedHostRepresentation.create(model.getHostName(), model.getCount(), model.getRemainingCount()); + } +} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java index 22441ad5e6..4fd941b728 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java @@ -207,6 +207,19 @@ public class RealmAdminResource { return resource; } + + /** + * Base path for managing client initial access tokens + * + * @return + */ + @Path("clients-trusted-hosts") + public ClientRegistrationTrustedHostResource getClientRegistrationTrustedHost() { + ClientRegistrationTrustedHostResource resource = new ClientRegistrationTrustedHostResource(realm, auth, adminEvent); + ResteasyProviderFactory.getInstance().injectProperties(resource); + return resource; + } + /** * Base path for managing components under this realm. * diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientRegTrustedHostTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientRegTrustedHostTest.java new file mode 100644 index 0000000000..f430e6ac3c --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientRegTrustedHostTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.admin; + +import java.util.List; + +import javax.ws.rs.core.Response; + +import org.junit.Before; +import org.junit.Test; +import org.keycloak.admin.client.resource.ClientRegistrationTrustedHostResource; +import org.keycloak.events.admin.OperationType; +import org.keycloak.events.admin.ResourceType; +import org.keycloak.representations.idm.ClientRegistrationTrustedHostRepresentation; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.util.AdminEventPaths; + +/** + * @author Marek Posolda + */ +public class ClientRegTrustedHostTest extends AbstractAdminTest { + + + private ClientRegistrationTrustedHostResource resource; + + @Before + public void before() { + resource = realm.clientRegistrationTrustedHost(); + } + + @Test + public void testInitialAccessTokens() { + + // Successfully create "localhost1" rep + ClientRegistrationTrustedHostRepresentation rep = new ClientRegistrationTrustedHostRepresentation(); + rep.setHostName("localhost1"); + rep.setCount(5); + + Response res = resource.create(rep); + assertAdminEvents.assertEvent(realmId, OperationType.CREATE, AdminEventPaths.clientRegistrationTrustedHostPath("localhost1"), rep, ResourceType.CLIENT_REGISTRATION_TRUSTED_HOST_MODEL); + res.close(); + + // Failed to create conflicting rep "localhost1" again + res = resource.create(rep); + Assert.assertEquals(409, res.getStatus()); + assertAdminEvents.assertEmpty(); + res.close(); + + // Successfully create "localhost2" rep + rep = new ClientRegistrationTrustedHostRepresentation(); + rep.setHostName("localhost2"); + rep.setCount(10); + + res = resource.create(rep); + assertAdminEvents.assertEvent(realmId, OperationType.CREATE, AdminEventPaths.clientRegistrationTrustedHostPath("localhost2"), rep, ResourceType.CLIENT_REGISTRATION_TRUSTED_HOST_MODEL); + res.close(); + + // Get "localhost1" + rep = resource.get("localhost1"); + assertRep(rep, "localhost1", 5, 5); + + // Update "localhost1" + rep.setCount(7); + rep.setRemainingCount(7); + resource.update("localhost1", rep); + assertAdminEvents.assertEvent(realmId, OperationType.UPDATE, AdminEventPaths.clientRegistrationTrustedHostPath("localhost1"), rep, ResourceType.CLIENT_REGISTRATION_TRUSTED_HOST_MODEL); + + // Get all + List alls = resource.list(); + Assert.assertEquals(2, alls.size()); + assertRep(findByHost(alls, "localhost1"), "localhost1", 7, 7); + assertRep(findByHost(alls, "localhost2"), "localhost2", 10, 10); + + // Delete "localhost1" + resource.delete("localhost1"); + assertAdminEvents.assertEvent(realmId, OperationType.DELETE, AdminEventPaths.clientRegistrationTrustedHostPath("localhost1"), ResourceType.CLIENT_REGISTRATION_TRUSTED_HOST_MODEL); + + // Get all and check just "localhost2" available + alls = resource.list(); + Assert.assertEquals(1, alls.size()); + assertRep(alls.get(0), "localhost2", 10, 10); + } + + private ClientRegistrationTrustedHostRepresentation findByHost(List list, String hostName) { + for (ClientRegistrationTrustedHostRepresentation rep : list) { + if (hostName.equals(rep.getHostName())) { + return rep; + } + } + return null; + } + + private void assertRep(ClientRegistrationTrustedHostRepresentation rep, String expectedHost, int expectedCount, int expectedRemaining) { + Assert.assertEquals(expectedHost, rep.getHostName()); + Assert.assertEquals(expectedCount, rep.getCount().intValue()); + Assert.assertEquals(expectedRemaining, rep.getRemainingCount().intValue()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java index 9b364e8d06..6ccb0d15d2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java @@ -20,7 +20,6 @@ package org.keycloak.testsuite.client; import org.junit.Before; import org.junit.Test; import org.keycloak.OAuth2Constants; -import org.keycloak.OAuthErrorException; import org.keycloak.client.registration.Auth; import org.keycloak.client.registration.ClientRegistrationException; import org.keycloak.client.registration.HttpErrorException; @@ -28,13 +27,15 @@ import org.keycloak.common.util.CollectionUtil; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation; import org.keycloak.representations.idm.ClientInitialAccessPresentation; -import org.keycloak.representations.idm.OAuth2ErrorRepresentation; +import org.keycloak.representations.idm.ClientRegistrationTrustedHostRepresentation; import org.keycloak.representations.oidc.OIDCClientRepresentation; import org.keycloak.testsuite.Assert; import java.util.Arrays; import java.util.Collections; +import javax.ws.rs.core.Response; + import static org.junit.Assert.*; /** @@ -50,11 +51,16 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest { reg.auth(Auth.token(token)); } - public OIDCClientRepresentation create() throws ClientRegistrationException { + private OIDCClientRepresentation createRep() { OIDCClientRepresentation client = new OIDCClientRepresentation(); client.setClientName("RegistrationAccessTokenTest"); client.setClientUri("http://root"); client.setRedirectUris(Collections.singletonList("http://redirect")); + return client; + } + + public OIDCClientRepresentation create() throws ClientRegistrationException { + OIDCClientRepresentation client = createRep(); OIDCClientRepresentation response = reg.oidc().create(client); @@ -62,21 +68,41 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest { } @Test - public void testMissingToken() throws Exception { + public void testCreateWithTrustedHost() throws Exception { reg.auth(null); - OIDCClientRepresentation client = new OIDCClientRepresentation(); - client.setClientName("RegistrationAccessTokenTest"); - client.setClientUri("http://root"); - client.setRedirectUris(Collections.singletonList("http://redirect")); + OIDCClientRepresentation client = createRep(); + // Failed to create client + try { + reg.oidc().create(client); + Assert.fail("Not expected to successfuly register client"); + } catch (ClientRegistrationException expected) { + HttpErrorException httpEx = (HttpErrorException) expected.getCause(); + Assert.assertEquals(401, httpEx.getStatusLine().getStatusCode()); + } + + // Create trusted host entry + Response response = adminClient.realm(REALM_NAME).clientRegistrationTrustedHost().create(ClientRegistrationTrustedHostRepresentation.create("localhost", 2, 2)); + Assert.assertEquals(201, response.getStatus()); + + // Successfully register client + reg.oidc().create(client); + + // Just one remaining available + ClientRegistrationTrustedHostRepresentation rep = adminClient.realm(REALM_NAME).clientRegistrationTrustedHost().get("localhost"); + Assert.assertEquals(1, rep.getRemainingCount().intValue()); + + // Successfully register client2 + reg.oidc().create(client); + + // Failed to create 3rd client try { reg.oidc().create(client); Assert.fail("Not expected to successfuly register client"); } catch (ClientRegistrationException expected) { HttpErrorException httpEx = (HttpErrorException) expected.getCause(); Assert.assertEquals(401, httpEx.getStatusLine().getStatusCode()); - Assert.assertEquals(OAuthErrorException.INVALID_TOKEN, httpEx.toErrorRepresentation().getError()); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java index dddc621e07..eddd1713e6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java @@ -317,6 +317,15 @@ public class AdminEventPaths { return uri.toString(); } + // CLIENT REGISTRATION TRUSTED HOSTS + + public static String clientRegistrationTrustedHostPath(String hostName) { + URI uri = UriBuilder.fromUri("").path(RealmResource.class, "clientRegistrationTrustedHost") + .path(ClientInitialAccessResource.class, "delete") + .build(hostName); + return uri.toString(); + } + // GROUPS public static String groupsPath() { diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 930cc36c6d..f20e034c45 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -511,12 +511,25 @@ remainingCount=Remaining Count created=Created back=Back initial-access-tokens=Initial Access Tokens +initial-access-tokens.tooltip=Initial Access Tokens for dynamic registrations of clients. Request with those tokens can be sent from any host. add-initial-access-tokens=Add Initial Access Token initial-access-token=Initial Access Token initial-access.copyPaste.tooltip=Copy/paste the initial access token before navigating away from this page as it's not posible to retrieve later continue=Continue initial-access-token.confirm.title=Copy Initial Access Token initial-access-token.confirm.text=Please copy and paste the initial access token before confirming as it can't be retrieved later +no-initial-access-available=No Initial Access Tokens available + +trusted-hosts-legend=Trusted Hosts For Client Registrations +trusted-hosts-legend.tooltip=Hosts, which are trusted for client registrations. Client registration requests from those hosts can be sent even without initial access token. The amount of client registrations from particular host can be limited to specified count. +no-client-trusted-hosts-available=No Trusted Hosts available +add-client-reg-trusted-host=Add Trusted Host +hostname=Hostname +client-reg-hostname.tooltip=Fully-Qualified Hostname or IP Address. Client registration requests from this host/address will be trusted and allowed to register new client. +client-reg-count.tooltip=Allowed count of client registration requests from particular host. You need to restart this once the limit is reached. +client-reg-remainingCount.tooltip=Remaining count of client registration requests from this host. You need to restart this once the limit is reached. +reset-remaining-count=Reset Remaining Count + client-templates=Client Templates client-templates.tooltip=Client templates allow you to define common configuration that is shared between multiple clients diff --git a/themes/src/main/resources/theme/base/admin/resources/js/app.js b/themes/src/main/resources/theme/base/admin/resources/js/app.js index d85c019656..c3b25ecb27 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/app.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/app.js @@ -208,6 +208,9 @@ module.config([ '$routeProvider', function($routeProvider) { }, clientInitialAccess : function(ClientInitialAccessLoader) { return ClientInitialAccessLoader(); + }, + clientRegTrustedHosts : function(ClientRegistrationTrustedHostListLoader) { + return ClientRegistrationTrustedHostListLoader(); } }, controller : 'ClientInitialAccessCtrl' @@ -221,6 +224,30 @@ module.config([ '$routeProvider', function($routeProvider) { }, controller : 'ClientInitialAccessCreateCtrl' }) + .when('/realms/:realm/client-reg-trusted-hosts/create', { + templateUrl : resourceUrl + '/partials/client-reg-trusted-host-create.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + clientRegTrustedHost : function() { + return {}; + } + }, + controller : 'ClientRegistrationTrustedHostDetailCtrl' + }) + .when('/realms/:realm/client-reg-trusted-hosts/:hostname', { + templateUrl : resourceUrl + '/partials/client-reg-trusted-host-detail.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + clientRegTrustedHost : function(ClientRegistrationTrustedHostLoader) { + return ClientRegistrationTrustedHostLoader(); + } + }, + controller : 'ClientRegistrationTrustedHostDetailCtrl' + }) .when('/realms/:realm/keys-settings', { templateUrl : resourceUrl + '/partials/realm-keys.html', resolve : { diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index 317b376477..f42ce55b22 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -2149,9 +2149,23 @@ module.controller('AuthenticationConfigCreateCtrl', function($scope, realm, flow }); -module.controller('ClientInitialAccessCtrl', function($scope, realm, clientInitialAccess, ClientInitialAccess, Dialog, Notifications, $route) { +module.controller('ClientInitialAccessCtrl', function($scope, realm, clientInitialAccess, clientRegTrustedHosts, ClientInitialAccess, ClientRegistrationTrustedHost, Dialog, Notifications, $route, $location) { $scope.realm = realm; $scope.clientInitialAccess = clientInitialAccess; + $scope.clientRegTrustedHosts = clientRegTrustedHosts; + + $scope.updateHost = function(hostname) { + $location.url('/realms/' + realm.realm + '/client-reg-trusted-hosts/' + hostname); + }; + + $scope.removeHost = function(hostname) { + Dialog.confirmDelete(hostname, 'trusted host for client registration', function() { + ClientRegistrationTrustedHost.remove({ realm: realm.realm, hostname: hostname }, function() { + Notifications.success("The trusted host for client registration was deleted."); + $route.reload(); + }); + }); + }; $scope.remove = function(id) { Dialog.confirmDelete(id, 'initial access token', function() { @@ -2163,6 +2177,57 @@ module.controller('ClientInitialAccessCtrl', function($scope, realm, clientIniti } }); +module.controller('ClientRegistrationTrustedHostDetailCtrl', function($scope, realm, clientRegTrustedHost, ClientRegistrationTrustedHost, Dialog, Notifications, $route, $location) { + $scope.realm = realm; + + $scope.create = !clientRegTrustedHost.hostName; + $scope.changed = false; + + if ($scope.create) { + $scope.count = 5; + } else { + $scope.hostName = clientRegTrustedHost.hostName; + $scope.count = clientRegTrustedHost.count; + $scope.remainingCount = clientRegTrustedHost.remainingCount; + } + + $scope.save = function() { + if ($scope.create) { + ClientRegistrationTrustedHost.save({ + realm: realm.realm + }, { hostName: $scope.hostName, count: $scope.count, remainingCount: $scope.count }, function (data) { + Notifications.success("The trusted host was created."); + $location.url('/realms/' + realm.realm + '/client-reg-trusted-hosts/' + $scope.hostName); + }); + } else { + ClientRegistrationTrustedHost.update({ + realm: realm.realm, hostname: $scope.hostName + }, { hostName: $scope.hostName, count: $scope.count, remainingCount: $scope.count }, function (data) { + Notifications.success("The trusted host was updated."); + $route.reload(); + }); + } + }; + + $scope.cancel = function() { + $location.url('/realms/' + realm.realm + '/client-initial-access'); + }; + + $scope.resetRemainingCount = function() { + $scope.save(); + } + + $scope.$watch('count', function(newVal, oldVal) { + if (oldVal == newVal) { + return; + } + + $scope.changed = true; + }); + +}); + + module.controller('ClientInitialAccessCreateCtrl', function($scope, realm, ClientInitialAccess, TimeUnit, Dialog, $location, $translate) { $scope.expirationUnit = 'Days'; $scope.expiration = TimeUnit.toUnit(0, $scope.expirationUnit); diff --git a/themes/src/main/resources/theme/base/admin/resources/js/loaders.js b/themes/src/main/resources/theme/base/admin/resources/js/loaders.js index 33fb7ac653..4b768633c7 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/loaders.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/loaders.js @@ -510,6 +510,23 @@ module.factory('ClientInitialAccessLoader', function(Loader, ClientInitialAccess }); }); +module.factory('ClientRegistrationTrustedHostListLoader', function(Loader, ClientRegistrationTrustedHost, $route) { + return Loader.query(ClientRegistrationTrustedHost, function() { + return { + realm: $route.current.params.realm + } + }); +}); + +module.factory('ClientRegistrationTrustedHostLoader', function(Loader, ClientRegistrationTrustedHost, $route) { + return Loader.get(ClientRegistrationTrustedHost, function() { + return { + realm: $route.current.params.realm, + hostname : $route.current.params.hostname + } + }); +}); + diff --git a/themes/src/main/resources/theme/base/admin/resources/js/services.js b/themes/src/main/resources/theme/base/admin/resources/js/services.js index 7e4df381ff..6d8fdcdcf1 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/services.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/services.js @@ -298,6 +298,18 @@ module.factory('ClientInitialAccess', function($resource) { }); }); +module.factory('ClientRegistrationTrustedHost', function($resource) { + return $resource(authUrl + '/admin/realms/:realm/clients-trusted-hosts/:hostname', { + realm : '@realm', + hostname : '@hostname' + }, { + update : { + method : 'PUT' + } + } + ); +}); + module.factory('ClientProtocolMapper', function($resource) { return $resource(authUrl + '/admin/realms/:realm/clients/:client/protocol-mappers/models/:id', { diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-initial-access.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-initial-access.html index 11b3dcf2ef..4bc9d6829e 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-initial-access.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-initial-access.html @@ -1,6 +1,59 @@
+
+ {{:: 'trusted-hosts-legend' | translate}} + {{:: 'trusted-hosts-legend.tooltip' | translate}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+ +
+
+
+ + +
+
{{:: 'hostname' | translate}}{{:: 'count' | translate}}{{:: 'remainingCount' | translate}}{{:: 'actions' | translate}}
{{host.hostName}}{{host.count}}{{host.remainingCount}}{{:: 'update' | translate}}{{:: 'delete' | translate}}
{{:: 'no-results' | translate}}{{:: 'no-client-trusted-hosts-available' | translate}}
+ + +
+ {{:: 'initial-access-tokens' | translate}} + {{:: 'initial-access-tokens.tooltip' | translate}} +
@@ -8,7 +61,7 @@
- +
@@ -21,7 +74,7 @@
- + @@ -31,7 +84,7 @@ - + @@ -39,9 +92,9 @@ - - - + + +
{{:: 'id' | translate}} {{:: 'created' | translate}} {{:: 'expires' | translate}}
{{ia.id}} {{(ia.timestamp * 1000)|date:'shortDate'}} {{(ia.timestamp * 1000)|date:'mediumTime'}} {{((ia.timestamp + ia.expiration) * 1000)|date:'shortDate'}} {{((ia.timestamp + ia.expiration) * 1000)|date:'mediumTime'}}{{ia.remainingCount}} {{:: 'delete' | translate}}
{{:: 'no-results' | translate}}{{:: 'no-clients-available' | translate}}
{{:: 'no-results' | translate}}{{:: 'no-initial-access-available' | translate}}
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-reg-trusted-host-create.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-reg-trusted-host-create.html new file mode 100644 index 0000000000..cb07613a58 --- /dev/null +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-reg-trusted-host-create.html @@ -0,0 +1,55 @@ + + +
+ + + +

{{:: 'add-client-reg-trusted-host' | translate}}

+ +
+ +
+ +
+ +
+ {{:: 'client-reg-hostname.tooltip' | translate}} +
+ +
+ +
+ +
+ {{:: 'client-reg-count.tooltip' | translate}} +
+ +
+
+ + +
+
+
+ +
+ + \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-reg-trusted-host-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-reg-trusted-host-detail.html new file mode 100644 index 0000000000..5644fc1dd9 --- /dev/null +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-reg-trusted-host-detail.html @@ -0,0 +1,64 @@ + + +
+ + + +

{{hostName}}

+ +
+ +
+ +
+ +
+ {{:: 'client-reg-hostname.tooltip' | translate}} +
+ +
+ +
+ +
+ {{:: 'client-reg-count.tooltip' | translate}} +
+ +
+ +
+ +
+ {{:: 'client-reg-remainingCount.tooltip' | translate}} +
+ +
+
+ + + +
+
+
+ +
+ + \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/admin/resources/templates/kc-menu.html b/themes/src/main/resources/theme/base/admin/resources/templates/kc-menu.html index e766bcb33e..96fb2472e2 100755 --- a/themes/src/main/resources/theme/base/admin/resources/templates/kc-menu.html +++ b/themes/src/main/resources/theme/base/admin/resources/templates/kc-menu.html @@ -25,6 +25,7 @@ || path[2] == 'theme-settings' || path[2] == 'token-settings' || path[2] == 'cache-settings' + || path[2] == 'client-initial-access' || path[2] == 'defense' || path[2] == 'keys-settings' || path[2] == 'smtp-settings' || path[2] == 'ldap-settings' || path[2] == 'auth-settings') && path[3] != 'clients') && 'active'"> {{:: 'realm-settings' | translate}}