ProtectedResourceRegistrationEndpoint.java

/*******************************************************************************
 * Copyright 2017 The MIT Internet Trust Consortium
 *
 * 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.mitre.openid.connect.web;

import java.io.UnsupportedEncodingException;
import java.text.ParseException;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;

import org.mitre.oauth2.model.ClientDetailsEntity;
import org.mitre.oauth2.model.ClientDetailsEntity.AuthMethod;
import org.mitre.oauth2.model.OAuth2AccessTokenEntity;
import org.mitre.oauth2.model.RegisteredClient;
import org.mitre.oauth2.model.SystemScope;
import org.mitre.oauth2.service.ClientDetailsEntityService;
import org.mitre.oauth2.service.OAuth2TokenEntityService;
import org.mitre.oauth2.service.SystemScopeService;
import org.mitre.openid.connect.ClientDetailsEntityJsonProcessor;
import org.mitre.openid.connect.config.ConfigurationPropertiesBean;
import org.mitre.openid.connect.exception.ValidationException;
import org.mitre.openid.connect.service.OIDCTokenService;
import org.mitre.openid.connect.view.ClientInformationResponseView;
import org.mitre.openid.connect.view.HttpCodeView;
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.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationDetails;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.util.UriUtils;

import com.google.common.base.Strings;
import com.google.gson.JsonSyntaxException;

@Controller
@RequestMapping(value = ProtectedResourceRegistrationEndpoint.URL)
public class ProtectedResourceRegistrationEndpoint {

	/**
	 *
	 */
	public static final String URL = "resource";

	@Autowired
	private ClientDetailsEntityService clientService;

	@Autowired
	private OAuth2TokenEntityService tokenService;

	@Autowired
	private SystemScopeService scopeService;

	@Autowired
	private ConfigurationPropertiesBean config;

	@Autowired
	private OIDCTokenService connectTokenService;

	/**
	 * Logger for this class
	 */
	private static final Logger logger = LoggerFactory.getLogger(ProtectedResourceRegistrationEndpoint.class);

	/**
	 * Create a new Client, issue a client ID, and create a registration access token.
	 * @param jsonString
	 * @param m
	 * @param p
	 * @return
	 */
	@RequestMapping(method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
	public String registerNewProtectedResource(@RequestBody String jsonString, Model m) {

		ClientDetailsEntity newClient = null;
		try {
			newClient = ClientDetailsEntityJsonProcessor.parse(jsonString);
		} catch (JsonSyntaxException e) {
			// bad parse
			// didn't parse, this is a bad request
			logger.error("registerNewProtectedResource failed; submitted JSON is malformed");
			m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); // http 400
			return HttpCodeView.VIEWNAME;
		}

		if (newClient != null) {
			// it parsed!

			//
			// Now do some post-processing consistency checks on it
			//

			// clear out any spurious id/secret (clients don't get to pick)
			newClient.setClientId(null);
			newClient.setClientSecret(null);

			// do validation on the fields
			try {
				newClient = validateScopes(newClient);
				newClient = validateAuth(newClient);
			} catch (ValidationException ve) {
				// validation failed, return an error
				m.addAttribute(JsonErrorView.ERROR, ve.getError());
				m.addAttribute(JsonErrorView.ERROR_MESSAGE, ve.getErrorDescription());
				m.addAttribute(HttpCodeView.CODE, ve.getStatus());
				return JsonErrorView.VIEWNAME;
			}


			// no grant types are allowed
			newClient.setGrantTypes(new HashSet<String>());
			newClient.setResponseTypes(new HashSet<String>());
			newClient.setRedirectUris(new HashSet<String>());

			// don't issue tokens to this client
			newClient.setAccessTokenValiditySeconds(0);
			newClient.setIdTokenValiditySeconds(0);
			newClient.setRefreshTokenValiditySeconds(0);

			// clear out unused fields
			newClient.setDefaultACRvalues(new HashSet<String>());
			newClient.setDefaultMaxAge(null);
			newClient.setIdTokenEncryptedResponseAlg(null);
			newClient.setIdTokenEncryptedResponseEnc(null);
			newClient.setIdTokenSignedResponseAlg(null);
			newClient.setInitiateLoginUri(null);
			newClient.setPostLogoutRedirectUris(null);
			newClient.setRequestObjectSigningAlg(null);
			newClient.setRequireAuthTime(null);
			newClient.setReuseRefreshToken(false);
			newClient.setSectorIdentifierUri(null);
			newClient.setSubjectType(null);
			newClient.setUserInfoEncryptedResponseAlg(null);
			newClient.setUserInfoEncryptedResponseEnc(null);
			newClient.setUserInfoSignedResponseAlg(null);

			// this client has been dynamically registered (obviously)
			newClient.setDynamicallyRegistered(true);

			// this client has access to the introspection endpoint
			newClient.setAllowIntrospection(true);

			// now save it
			try {
				ClientDetailsEntity savedClient = clientService.saveNewClient(newClient);

				// generate the registration access token
				OAuth2AccessTokenEntity token = connectTokenService.createResourceAccessToken(savedClient);
				tokenService.saveAccessToken(token);

				// send it all out to the view

				RegisteredClient registered = new RegisteredClient(savedClient, token.getValue(), config.getIssuer() + "resource/" + UriUtils.encodePathSegment(savedClient.getClientId(), "UTF-8"));
				m.addAttribute("client", registered);
				m.addAttribute(HttpCodeView.CODE, HttpStatus.CREATED); // http 201

				return ClientInformationResponseView.VIEWNAME;
			} catch (UnsupportedEncodingException e) {
				logger.error("Unsupported encoding", e);
				m.addAttribute(HttpCodeView.CODE, HttpStatus.INTERNAL_SERVER_ERROR);
				return HttpCodeView.VIEWNAME;
			} catch (IllegalArgumentException e) {
				logger.error("Couldn't save client", e);

				m.addAttribute(JsonErrorView.ERROR, "invalid_client_metadata");
				m.addAttribute(JsonErrorView.ERROR_MESSAGE, "Unable to save client due to invalid or inconsistent metadata.");
				m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); // http 400

				return JsonErrorView.VIEWNAME;
			}
		} else {
			// didn't parse, this is a bad request
			logger.error("registerNewClient failed; submitted JSON is malformed");
			m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); // http 400

			return HttpCodeView.VIEWNAME;
		}

	}

	private ClientDetailsEntity validateScopes(ClientDetailsEntity newClient) throws ValidationException {
		// scopes that the client is asking for
		Set<SystemScope> requestedScopes = scopeService.fromStrings(newClient.getScope());

		// the scopes that the client can have must be a subset of the dynamically allowed scopes
		Set<SystemScope> allowedScopes = scopeService.removeRestrictedAndReservedScopes(requestedScopes);

		// if the client didn't ask for any, give them the defaults
		if (allowedScopes == null || allowedScopes.isEmpty()) {
			allowedScopes = scopeService.getDefaults();
		}

		newClient.setScope(scopeService.toStrings(allowedScopes));

		return newClient;
	}

	/**
	 * Get the meta information for a client.
	 * @param clientId
	 * @param m
	 * @param auth
	 * @return
	 */
	@PreAuthorize("hasRole('ROLE_CLIENT') and #oauth2.hasScope('" + SystemScopeService.RESOURCE_TOKEN_SCOPE + "')")
	@RequestMapping(value = "/{id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
	public String readResourceConfiguration(@PathVariable("id") String clientId, Model m, OAuth2Authentication auth) {

		ClientDetailsEntity client = clientService.loadClientByClientId(clientId);

		if (client != null && client.getClientId().equals(auth.getOAuth2Request().getClientId())) {



			try {
				// possibly update the token
				OAuth2AccessTokenEntity token = fetchValidRegistrationToken(auth, client);

				RegisteredClient registered = new RegisteredClient(client, token.getValue(), config.getIssuer() + "resource/" +  UriUtils.encodePathSegment(client.getClientId(), "UTF-8"));

				// send it all out to the view
				m.addAttribute("client", registered);
				m.addAttribute(HttpCodeView.CODE, HttpStatus.OK); // http 200

				return ClientInformationResponseView.VIEWNAME;
			} catch (UnsupportedEncodingException e) {
				logger.error("Unsupported encoding", e);
				m.addAttribute(HttpCodeView.CODE, HttpStatus.INTERNAL_SERVER_ERROR);
				return HttpCodeView.VIEWNAME;
			}
		} else {
			// client mismatch
			logger.error("readResourceConfiguration failed, client ID mismatch: "
					+ clientId + " and " + auth.getOAuth2Request().getClientId() + " do not match.");
			m.addAttribute(HttpCodeView.CODE, HttpStatus.FORBIDDEN); // http 403

			return HttpCodeView.VIEWNAME;
		}
	}

	/**
	 * Update the metainformation for a given client.
	 * @param clientId
	 * @param jsonString
	 * @param m
	 * @param auth
	 * @return
	 */
	@PreAuthorize("hasRole('ROLE_CLIENT') and #oauth2.hasScope('" + SystemScopeService.RESOURCE_TOKEN_SCOPE + "')")
	@RequestMapping(value = "/{id}", method = RequestMethod.PUT, produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
	public String updateProtectedResource(@PathVariable("id") String clientId, @RequestBody String jsonString, Model m, OAuth2Authentication auth) {


		ClientDetailsEntity newClient = null;
		try {
			newClient = ClientDetailsEntityJsonProcessor.parse(jsonString);
		} catch (JsonSyntaxException e) {
			// bad parse
			// didn't parse, this is a bad request
			logger.error("updateProtectedResource failed; submitted JSON is malformed");
			m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); // http 400
			return HttpCodeView.VIEWNAME;
		}

		ClientDetailsEntity oldClient = clientService.loadClientByClientId(clientId);

		if (newClient != null && oldClient != null  // we have an existing client and the new one parsed
				&& oldClient.getClientId().equals(auth.getOAuth2Request().getClientId()) // the client passed in the URI matches the one in the auth
				&& oldClient.getClientId().equals(newClient.getClientId()) // the client passed in the body matches the one in the URI
				) {

			// a client can't ask to update its own client secret to any particular value
			newClient.setClientSecret(oldClient.getClientSecret());

			newClient.setCreatedAt(oldClient.getCreatedAt());

			// no grant types are allowed
			newClient.setGrantTypes(new HashSet<String>());
			newClient.setResponseTypes(new HashSet<String>());
			newClient.setRedirectUris(new HashSet<String>());

			// don't issue tokens to this client
			newClient.setAccessTokenValiditySeconds(0);
			newClient.setIdTokenValiditySeconds(0);
			newClient.setRefreshTokenValiditySeconds(0);

			// clear out unused fields
			newClient.setDefaultACRvalues(new HashSet<String>());
			newClient.setDefaultMaxAge(null);
			newClient.setIdTokenEncryptedResponseAlg(null);
			newClient.setIdTokenEncryptedResponseEnc(null);
			newClient.setIdTokenSignedResponseAlg(null);
			newClient.setInitiateLoginUri(null);
			newClient.setPostLogoutRedirectUris(null);
			newClient.setRequestObjectSigningAlg(null);
			newClient.setRequireAuthTime(null);
			newClient.setReuseRefreshToken(false);
			newClient.setSectorIdentifierUri(null);
			newClient.setSubjectType(null);
			newClient.setUserInfoEncryptedResponseAlg(null);
			newClient.setUserInfoEncryptedResponseEnc(null);
			newClient.setUserInfoSignedResponseAlg(null);

			// this client has been dynamically registered (obviously)
			newClient.setDynamicallyRegistered(true);

			// this client has access to the introspection endpoint
			newClient.setAllowIntrospection(true);

			// do validation on the fields
			try {
				newClient = validateScopes(newClient);
				newClient = validateAuth(newClient);
			} catch (ValidationException ve) {
				// validation failed, return an error
				m.addAttribute(JsonErrorView.ERROR, ve.getError());
				m.addAttribute(JsonErrorView.ERROR_MESSAGE, ve.getErrorDescription());
				m.addAttribute(HttpCodeView.CODE, ve.getStatus());
				return JsonErrorView.VIEWNAME;
			}


			try {
				// save the client
				ClientDetailsEntity savedClient = clientService.updateClient(oldClient, newClient);

				// possibly update the token
				OAuth2AccessTokenEntity token = fetchValidRegistrationToken(auth, savedClient);

				RegisteredClient registered = new RegisteredClient(savedClient, token.getValue(), config.getIssuer() + "resource/" + UriUtils.encodePathSegment(savedClient.getClientId(), "UTF-8"));

				// send it all out to the view
				m.addAttribute("client", registered);
				m.addAttribute(HttpCodeView.CODE, HttpStatus.OK); // http 200

				return ClientInformationResponseView.VIEWNAME;
			} catch (UnsupportedEncodingException e) {
				logger.error("Unsupported encoding", e);
				m.addAttribute(HttpCodeView.CODE, HttpStatus.INTERNAL_SERVER_ERROR);
				return HttpCodeView.VIEWNAME;
			} catch (IllegalArgumentException e) {
				logger.error("Couldn't save client", e);

				m.addAttribute(JsonErrorView.ERROR, "invalid_client_metadata");
				m.addAttribute(JsonErrorView.ERROR_MESSAGE, "Unable to save client due to invalid or inconsistent metadata.");
				m.addAttribute(HttpCodeView.CODE, HttpStatus.BAD_REQUEST); // http 400

				return JsonErrorView.VIEWNAME;
			}
		} else {
			// client mismatch
			logger.error("updateProtectedResource" +
					" failed, client ID mismatch: "
					+ clientId + " and " + auth.getOAuth2Request().getClientId() + " do not match.");
			m.addAttribute(HttpCodeView.CODE, HttpStatus.FORBIDDEN); // http 403

			return HttpCodeView.VIEWNAME;
		}
	}

	/**
	 * Delete the indicated client from the system.
	 * @param clientId
	 * @param m
	 * @param auth
	 * @return
	 */
	@PreAuthorize("hasRole('ROLE_CLIENT') and #oauth2.hasScope('" + SystemScopeService.RESOURCE_TOKEN_SCOPE + "')")
	@RequestMapping(value = "/{id}", method = RequestMethod.DELETE, produces = MediaType.APPLICATION_JSON_VALUE)
	public String deleteResource(@PathVariable("id") String clientId, Model m, OAuth2Authentication auth) {

		ClientDetailsEntity client = clientService.loadClientByClientId(clientId);

		if (client != null && client.getClientId().equals(auth.getOAuth2Request().getClientId())) {

			clientService.deleteClient(client);

			m.addAttribute(HttpCodeView.CODE, HttpStatus.NO_CONTENT); // http 204

			return HttpCodeView.VIEWNAME;
		} else {
			// client mismatch
			logger.error("readClientConfiguration failed, client ID mismatch: "
					+ clientId + " and " + auth.getOAuth2Request().getClientId() + " do not match.");
			m.addAttribute(HttpCodeView.CODE, HttpStatus.FORBIDDEN); // http 403

			return HttpCodeView.VIEWNAME;
		}
	}

	private ClientDetailsEntity validateAuth(ClientDetailsEntity newClient) throws ValidationException {
		if (newClient.getTokenEndpointAuthMethod() == null) {
			newClient.setTokenEndpointAuthMethod(AuthMethod.SECRET_BASIC);
		}

		if (newClient.getTokenEndpointAuthMethod() == AuthMethod.SECRET_BASIC ||
				newClient.getTokenEndpointAuthMethod() == AuthMethod.SECRET_JWT ||
				newClient.getTokenEndpointAuthMethod() == AuthMethod.SECRET_POST) {

			if (Strings.isNullOrEmpty(newClient.getClientSecret())) {
				// no secret yet, we need to generate a secret
				newClient = clientService.generateClientSecret(newClient);
			}
		} else if (newClient.getTokenEndpointAuthMethod() == AuthMethod.PRIVATE_KEY) {
			if (Strings.isNullOrEmpty(newClient.getJwksUri()) && newClient.getJwks() == null) {
				throw new ValidationException("invalid_client_metadata", "JWK Set URI required when using private key authentication", HttpStatus.BAD_REQUEST);
			}

			newClient.setClientSecret(null);
		} else if (newClient.getTokenEndpointAuthMethod() == AuthMethod.NONE) {
			newClient.setClientSecret(null);
		} else {
			throw new ValidationException("invalid_client_metadata", "Unknown authentication method", HttpStatus.BAD_REQUEST);
		}
		return newClient;
	}

	private OAuth2AccessTokenEntity fetchValidRegistrationToken(OAuth2Authentication auth, ClientDetailsEntity client) {

		OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) auth.getDetails();
		OAuth2AccessTokenEntity token = tokenService.readAccessToken(details.getTokenValue());

		if (config.getRegTokenLifeTime() != null) {

			try {
				// Re-issue the token if it has been issued before [currentTime - validity]
				Date validToDate = new Date(System.currentTimeMillis() - config.getRegTokenLifeTime() * 1000);
				if(token.getJwt().getJWTClaimsSet().getIssueTime().before(validToDate)) {
					logger.info("Rotating the registration access token for " + client.getClientId());
					tokenService.revokeAccessToken(token);
					OAuth2AccessTokenEntity newToken = connectTokenService.createResourceAccessToken(client);
					tokenService.saveAccessToken(newToken);
					return newToken;
				} else {
					// it's not expired, keep going
					return token;
				}
			} catch (ParseException e) {
				logger.error("Couldn't parse a known-valid token?", e);
				return token;
			}
		} else {
			// tokens don't expire, just return it
			return token;
		}
	}

}