|
|
|
@ -20,13 +20,18 @@ import java.lang.reflect.Type;
|
|
|
|
|
import java.sql.SQLIntegrityConstraintViolationException;
|
|
|
|
|
import java.text.ParseException;
|
|
|
|
|
import java.util.Collection;
|
|
|
|
|
|
|
|
|
|
import javax.persistence.PersistenceException;
|
|
|
|
|
|
|
|
|
|
import org.eclipse.persistence.exceptions.DatabaseException;
|
|
|
|
|
import org.mitre.jwt.assertion.AssertionValidator;
|
|
|
|
|
import org.mitre.oauth2.model.ClientDetailsEntity;
|
|
|
|
|
import org.mitre.oauth2.model.ClientDetailsEntity.AppType;
|
|
|
|
|
import org.mitre.oauth2.model.ClientDetailsEntity.AuthMethod;
|
|
|
|
|
import org.mitre.oauth2.model.ClientDetailsEntity.SubjectType;
|
|
|
|
|
import org.mitre.oauth2.service.ClientDetailsEntityService;
|
|
|
|
|
import org.mitre.oauth2.web.AuthenticationUtilities;
|
|
|
|
|
import org.mitre.openid.connect.exception.ValidationException;
|
|
|
|
|
import org.mitre.openid.connect.model.CachedImage;
|
|
|
|
|
import org.mitre.openid.connect.service.ClientLogoLoadingService;
|
|
|
|
|
import org.mitre.openid.connect.view.ClientEntityViewForAdmins;
|
|
|
|
@ -37,12 +42,14 @@ import org.mitre.openid.connect.view.JsonErrorView;
|
|
|
|
|
import org.slf4j.Logger;
|
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Qualifier;
|
|
|
|
|
import org.springframework.http.HttpHeaders;
|
|
|
|
|
import org.springframework.http.HttpStatus;
|
|
|
|
|
import org.springframework.http.MediaType;
|
|
|
|
|
import org.springframework.http.ResponseEntity;
|
|
|
|
|
import org.springframework.security.access.prepost.PreAuthorize;
|
|
|
|
|
import org.springframework.security.core.Authentication;
|
|
|
|
|
import org.springframework.security.oauth2.common.util.OAuth2Utils;
|
|
|
|
|
import org.springframework.stereotype.Controller;
|
|
|
|
|
import org.springframework.ui.Model;
|
|
|
|
|
import org.springframework.web.bind.annotation.PathVariable;
|
|
|
|
@ -52,6 +59,7 @@ import org.springframework.web.bind.annotation.RequestMethod;
|
|
|
|
|
import org.springframework.web.servlet.ModelAndView;
|
|
|
|
|
|
|
|
|
|
import com.google.common.base.Strings;
|
|
|
|
|
import com.google.common.collect.Sets;
|
|
|
|
|
import com.google.gson.Gson;
|
|
|
|
|
import com.google.gson.GsonBuilder;
|
|
|
|
|
import com.google.gson.JsonDeserializationContext;
|
|
|
|
@ -60,12 +68,57 @@ import com.google.gson.JsonElement;
|
|
|
|
|
import com.google.gson.JsonObject;
|
|
|
|
|
import com.google.gson.JsonParseException;
|
|
|
|
|
import com.google.gson.JsonParser;
|
|
|
|
|
import com.google.gson.JsonPrimitive;
|
|
|
|
|
import com.google.gson.JsonSerializationContext;
|
|
|
|
|
import com.google.gson.JsonSerializer;
|
|
|
|
|
import com.google.gson.JsonSyntaxException;
|
|
|
|
|
import com.nimbusds.jose.Algorithm;
|
|
|
|
|
import com.nimbusds.jose.EncryptionMethod;
|
|
|
|
|
import com.nimbusds.jose.JWEAlgorithm;
|
|
|
|
|
import com.nimbusds.jose.JWSAlgorithm;
|
|
|
|
|
import com.nimbusds.jose.jwk.JWKSet;
|
|
|
|
|
import com.nimbusds.jwt.JWT;
|
|
|
|
|
import com.nimbusds.jwt.JWTClaimsSet;
|
|
|
|
|
import com.nimbusds.jwt.JWTParser;
|
|
|
|
|
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.APPLICATION_TYPE;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.CLAIMS_REDIRECT_URIS;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.CLIENT_ID;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.CLIENT_ID_ISSUED_AT;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.CLIENT_NAME;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.CLIENT_SECRET;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.CLIENT_SECRET_EXPIRES_AT;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.CLIENT_URI;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.CONTACTS;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.DEFAULT_ACR_VALUES;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.DEFAULT_MAX_AGE;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.GRANT_TYPES;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.ID_TOKEN_ENCRYPTED_RESPONSE_ALG;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.ID_TOKEN_ENCRYPTED_RESPONSE_ENC;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.ID_TOKEN_SIGNED_RESPONSE_ALG;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.INITIATE_LOGIN_URI;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.JWKS;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.JWKS_URI;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.LOGO_URI;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.POLICY_URI;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.POST_LOGOUT_REDIRECT_URIS;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.REDIRECT_URIS;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.REGISTRATION_ACCESS_TOKEN;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.REGISTRATION_CLIENT_URI;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.REQUEST_OBJECT_SIGNING_ALG;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.REQUEST_URIS;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.REQUIRE_AUTH_TIME;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.RESPONSE_TYPES;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.SCOPE;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.SECTOR_IDENTIFIER_URI;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.SOFTWARE_STATEMENT;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.SUBJECT_TYPE;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.TOKEN_ENDPOINT_AUTH_METHOD;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.TOKEN_ENDPOINT_AUTH_SIGNING_ALG;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.TOS_URI;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.USERINFO_ENCRYPTED_RESPONSE_ALG;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.USERINFO_ENCRYPTED_RESPONSE_ENC;
|
|
|
|
|
import static org.mitre.oauth2.model.RegisteredClientFields.USERINFO_SIGNED_RESPONSE_ALG;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @author Michael Jett <mjett@mitre.org>
|
|
|
|
@ -83,6 +136,10 @@ public class ClientAPI {
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private ClientLogoLoadingService clientLogoLoadingService;
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
@Qualifier("clientAssertionValidator")
|
|
|
|
|
private AssertionValidator assertionValidator;
|
|
|
|
|
|
|
|
|
|
private JsonParser parser = new JsonParser();
|
|
|
|
|
|
|
|
|
@ -132,6 +189,20 @@ public class ClientAPI {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.registerTypeAdapter(JWT.class, new JsonDeserializer<JWT>() {
|
|
|
|
|
@Override
|
|
|
|
|
public JWT deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
|
|
|
|
if (json.isJsonPrimitive()) {
|
|
|
|
|
try {
|
|
|
|
|
return JWTParser.parse(json.getAsString());
|
|
|
|
|
} catch (ParseException e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")
|
|
|
|
|
.create();
|
|
|
|
|
|
|
|
|
@ -175,8 +246,8 @@ public class ClientAPI {
|
|
|
|
|
try {
|
|
|
|
|
json = parser.parse(jsonString).getAsJsonObject();
|
|
|
|
|
client = gson.fromJson(json, ClientDetailsEntity.class);
|
|
|
|
|
}
|
|
|
|
|
catch (JsonSyntaxException e) {
|
|
|
|
|
client = validateSoftwareStatement(client);
|
|
|
|
|
} catch (JsonSyntaxException e) {
|
|
|
|
|
logger.error("apiAddClient failed due to JsonSyntaxException", e);
|
|
|
|
|
m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST);
|
|
|
|
|
m.addAttribute(JsonErrorView.ERROR_MESSAGE, "Could not save new client. The server encountered a JSON syntax exception. Contact a system administrator for assistance.");
|
|
|
|
@ -186,6 +257,11 @@ public class ClientAPI {
|
|
|
|
|
m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST);
|
|
|
|
|
m.addAttribute(JsonErrorView.ERROR_MESSAGE, "Could not save new client. The server encountered an IllegalStateException. Refresh and try again - if the problem persists, contact a system administrator for assistance.");
|
|
|
|
|
return JsonErrorView.VIEWNAME;
|
|
|
|
|
} catch (ValidationException e) {
|
|
|
|
|
logger.error("apiUpdateClient failed due to ValidationException", e);
|
|
|
|
|
m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST);
|
|
|
|
|
m.addAttribute(JsonErrorView.ERROR_MESSAGE, "Could not update client. The server encountered a ValidationException.");
|
|
|
|
|
return JsonErrorView.VIEWNAME;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// if they leave the client identifier empty, force it to be generated
|
|
|
|
@ -281,8 +357,8 @@ public class ClientAPI {
|
|
|
|
|
// parse the client passed in (from JSON) and fetch the old client from the store
|
|
|
|
|
json = parser.parse(jsonString).getAsJsonObject();
|
|
|
|
|
client = gson.fromJson(json, ClientDetailsEntity.class);
|
|
|
|
|
}
|
|
|
|
|
catch (JsonSyntaxException e) {
|
|
|
|
|
client = validateSoftwareStatement(client);
|
|
|
|
|
} catch (JsonSyntaxException e) {
|
|
|
|
|
logger.error("apiUpdateClient failed due to JsonSyntaxException", e);
|
|
|
|
|
m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST);
|
|
|
|
|
m.addAttribute(JsonErrorView.ERROR_MESSAGE, "Could not update client. The server encountered a JSON syntax exception. Contact a system administrator for assistance.");
|
|
|
|
@ -292,6 +368,11 @@ public class ClientAPI {
|
|
|
|
|
m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST);
|
|
|
|
|
m.addAttribute(JsonErrorView.ERROR_MESSAGE, "Could not update client. The server encountered an IllegalStateException. Refresh and try again - if the problem persists, contact a system administrator for assistance.");
|
|
|
|
|
return JsonErrorView.VIEWNAME;
|
|
|
|
|
} catch (ValidationException e) {
|
|
|
|
|
logger.error("apiUpdateClient failed due to ValidationException", e);
|
|
|
|
|
m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST);
|
|
|
|
|
m.addAttribute(JsonErrorView.ERROR_MESSAGE, "Could not update client. The server encountered a ValidationException.");
|
|
|
|
|
return JsonErrorView.VIEWNAME;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ClientDetailsEntity oldClient = clientService.getClientById(id);
|
|
|
|
@ -441,4 +522,142 @@ public class ClientAPI {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private ClientDetailsEntity validateSoftwareStatement(ClientDetailsEntity newClient) throws ValidationException {
|
|
|
|
|
if (newClient.getSoftwareStatement() != null) {
|
|
|
|
|
if (assertionValidator.isValid(newClient.getSoftwareStatement())) {
|
|
|
|
|
// we have a software statement and its envelope passed all the checks from our validator
|
|
|
|
|
|
|
|
|
|
// swap out all of the client's fields for the associated parts of the software statement
|
|
|
|
|
try {
|
|
|
|
|
JWTClaimsSet claimSet = newClient.getSoftwareStatement().getJWTClaimsSet();
|
|
|
|
|
for (String claim : claimSet.getClaims().keySet()) {
|
|
|
|
|
switch (claim) {
|
|
|
|
|
case SOFTWARE_STATEMENT:
|
|
|
|
|
throw new ValidationException("invalid_client_metadata", "Software statement can't include another software statement", HttpStatus.BAD_REQUEST);
|
|
|
|
|
case CLAIMS_REDIRECT_URIS:
|
|
|
|
|
newClient.setClaimsRedirectUris(Sets.newHashSet(claimSet.getStringListClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case CLIENT_SECRET_EXPIRES_AT:
|
|
|
|
|
throw new ValidationException("invalid_client_metadata", "Software statement can't include a client secret expiration time", HttpStatus.BAD_REQUEST);
|
|
|
|
|
case CLIENT_ID_ISSUED_AT:
|
|
|
|
|
throw new ValidationException("invalid_client_metadata", "Software statement can't include a client ID issuance time", HttpStatus.BAD_REQUEST);
|
|
|
|
|
case REGISTRATION_CLIENT_URI:
|
|
|
|
|
throw new ValidationException("invalid_client_metadata", "Software statement can't include a client configuration endpoint", HttpStatus.BAD_REQUEST);
|
|
|
|
|
case REGISTRATION_ACCESS_TOKEN:
|
|
|
|
|
throw new ValidationException("invalid_client_metadata", "Software statement can't include a client registration access token", HttpStatus.BAD_REQUEST);
|
|
|
|
|
case REQUEST_URIS:
|
|
|
|
|
newClient.setRequestUris(Sets.newHashSet(claimSet.getStringListClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case POST_LOGOUT_REDIRECT_URIS:
|
|
|
|
|
newClient.setPostLogoutRedirectUris(Sets.newHashSet(claimSet.getStringListClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case INITIATE_LOGIN_URI:
|
|
|
|
|
newClient.setInitiateLoginUri(claimSet.getStringClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case DEFAULT_ACR_VALUES:
|
|
|
|
|
newClient.setDefaultACRvalues(Sets.newHashSet(claimSet.getStringListClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case REQUIRE_AUTH_TIME:
|
|
|
|
|
newClient.setRequireAuthTime(claimSet.getBooleanClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case DEFAULT_MAX_AGE:
|
|
|
|
|
newClient.setDefaultMaxAge(claimSet.getIntegerClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case TOKEN_ENDPOINT_AUTH_SIGNING_ALG:
|
|
|
|
|
newClient.setTokenEndpointAuthSigningAlg(JWSAlgorithm.parse(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case ID_TOKEN_ENCRYPTED_RESPONSE_ENC:
|
|
|
|
|
newClient.setIdTokenEncryptedResponseEnc(EncryptionMethod.parse(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case ID_TOKEN_ENCRYPTED_RESPONSE_ALG:
|
|
|
|
|
newClient.setIdTokenEncryptedResponseAlg(JWEAlgorithm.parse(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case ID_TOKEN_SIGNED_RESPONSE_ALG:
|
|
|
|
|
newClient.setIdTokenSignedResponseAlg(JWSAlgorithm.parse(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case USERINFO_ENCRYPTED_RESPONSE_ENC:
|
|
|
|
|
newClient.setUserInfoEncryptedResponseEnc(EncryptionMethod.parse(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case USERINFO_ENCRYPTED_RESPONSE_ALG:
|
|
|
|
|
newClient.setUserInfoEncryptedResponseAlg(JWEAlgorithm.parse(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case USERINFO_SIGNED_RESPONSE_ALG:
|
|
|
|
|
newClient.setUserInfoSignedResponseAlg(JWSAlgorithm.parse(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case REQUEST_OBJECT_SIGNING_ALG:
|
|
|
|
|
newClient.setRequestObjectSigningAlg(JWSAlgorithm.parse(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case SUBJECT_TYPE:
|
|
|
|
|
newClient.setSubjectType(SubjectType.getByValue(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case SECTOR_IDENTIFIER_URI:
|
|
|
|
|
newClient.setSectorIdentifierUri(claimSet.getStringClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case APPLICATION_TYPE:
|
|
|
|
|
newClient.setApplicationType(AppType.getByValue(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case JWKS_URI:
|
|
|
|
|
newClient.setJwksUri(claimSet.getStringClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case JWKS:
|
|
|
|
|
newClient.setJwks(JWKSet.parse(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case POLICY_URI:
|
|
|
|
|
newClient.setPolicyUri(claimSet.getStringClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case RESPONSE_TYPES:
|
|
|
|
|
newClient.setResponseTypes(Sets.newHashSet(claimSet.getStringListClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case GRANT_TYPES:
|
|
|
|
|
newClient.setGrantTypes(Sets.newHashSet(claimSet.getStringListClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case SCOPE:
|
|
|
|
|
newClient.setScope(OAuth2Utils.parseParameterList(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case TOKEN_ENDPOINT_AUTH_METHOD:
|
|
|
|
|
newClient.setTokenEndpointAuthMethod(AuthMethod.getByValue(claimSet.getStringClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case TOS_URI:
|
|
|
|
|
newClient.setTosUri(claimSet.getStringClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case CONTACTS:
|
|
|
|
|
newClient.setContacts(Sets.newHashSet(claimSet.getStringListClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case LOGO_URI:
|
|
|
|
|
newClient.setLogoUri(claimSet.getStringClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case CLIENT_URI:
|
|
|
|
|
newClient.setClientUri(claimSet.getStringClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case CLIENT_NAME:
|
|
|
|
|
newClient.setClientName(claimSet.getStringClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
case REDIRECT_URIS:
|
|
|
|
|
newClient.setRedirectUris(Sets.newHashSet(claimSet.getStringListClaim(claim)));
|
|
|
|
|
break;
|
|
|
|
|
case CLIENT_SECRET:
|
|
|
|
|
throw new ValidationException("invalid_client_metadata", "Software statement can't contain client secret", HttpStatus.BAD_REQUEST);
|
|
|
|
|
case CLIENT_ID:
|
|
|
|
|
throw new ValidationException("invalid_client_metadata", "Software statement can't contain client ID", HttpStatus.BAD_REQUEST);
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
logger.warn("Software statement contained unknown field: " + claim + " with value " + claimSet.getClaim(claim));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return newClient;
|
|
|
|
|
} catch (ParseException e) {
|
|
|
|
|
throw new ValidationException("invalid_client_metadata", "Software statement claims didn't parse", HttpStatus.BAD_REQUEST);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
throw new ValidationException("invalid_client_metadata", "Software statement rejected by validator", HttpStatus.BAD_REQUEST);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// nothing to see here, carry on
|
|
|
|
|
return newClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|