Java tutorial
/******************************************************************************* * Copyright 2016 The MITRE Corporation * and 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 java.util.concurrent.TimeUnit; import org.mitre.jwt.signer.service.JWTSigningAndValidationService; 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.BlacklistedSiteService; 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.common.collect.ImmutableSet; import com.google.common.collect.Sets; import com.google.gson.JsonSyntaxException; @Controller @RequestMapping(value = DynamicClientRegistrationEndpoint.URL) public class DynamicClientRegistrationEndpoint { public static final String URL = "register"; @Autowired private ClientDetailsEntityService clientService; @Autowired private OAuth2TokenEntityService tokenService; @Autowired private JWTSigningAndValidationService jwtService; @Autowired private SystemScopeService scopeService; @Autowired private BlacklistedSiteService blacklistService; @Autowired private ConfigurationPropertiesBean config; @Autowired private OIDCTokenService connectTokenService; /** * Logger for this class */ private static final Logger logger = LoggerFactory.getLogger(DynamicClientRegistrationEndpoint.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 registerNewClient(@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("registerNewClient 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 = validateResponseTypes(newClient); newClient = validateGrantTypes(newClient); newClient = validateRedirectUris(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; } 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) { // we need to generate a secret newClient = clientService.generateClientSecret(newClient); } // set some defaults for token timeouts if (config.isHeartMode()) { // heart mode has different defaults depending on primary grant type if (newClient.getGrantTypes().contains("authorization_code")) { newClient.setAccessTokenValiditySeconds((int) TimeUnit.HOURS.toSeconds(1)); // access tokens good for 1hr newClient.setIdTokenValiditySeconds((int) TimeUnit.MINUTES.toSeconds(5)); // id tokens good for 5min newClient.setRefreshTokenValiditySeconds((int) TimeUnit.HOURS.toSeconds(24)); // refresh tokens good for 24hr } else if (newClient.getGrantTypes().contains("implicit")) { newClient.setAccessTokenValiditySeconds((int) TimeUnit.MINUTES.toSeconds(15)); // access tokens good for 15min newClient.setIdTokenValiditySeconds((int) TimeUnit.MINUTES.toSeconds(5)); // id tokens good for 5min newClient.setRefreshTokenValiditySeconds(0); // no refresh tokens } else if (newClient.getGrantTypes().contains("client_credentials")) { newClient.setAccessTokenValiditySeconds((int) TimeUnit.HOURS.toSeconds(6)); // access tokens good for 6hr newClient.setIdTokenValiditySeconds(0); // no id tokens newClient.setRefreshTokenValiditySeconds(0); // no refresh tokens } } else { newClient.setAccessTokenValiditySeconds((int) TimeUnit.HOURS.toSeconds(1)); // access tokens good for 1hr newClient.setIdTokenValiditySeconds((int) TimeUnit.MINUTES.toSeconds(10)); // id tokens good for 10min newClient.setRefreshTokenValiditySeconds(null); // refresh tokens good until revoked } // this client has been dynamically registered (obviously) newClient.setDynamicallyRegistered(true); // this client can't do token introspection newClient.setAllowIntrospection(false); // now save it try { ClientDetailsEntity savedClient = clientService.saveNewClient(newClient); // generate the registration access token OAuth2AccessTokenEntity token = connectTokenService.createRegistrationAccessToken(savedClient); token = tokenService.saveAccessToken(token); // send it all out to the view RegisteredClient registered = new RegisteredClient(savedClient, token.getValue(), config.getIssuer() + "register/" + 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; } } /** * Get the meta information for a client. * @param clientId * @param m * @param auth * @return */ @PreAuthorize("hasRole('ROLE_CLIENT') and #oauth2.hasScope('" + SystemScopeService.REGISTRATION_TOKEN_SCOPE + "')") @RequestMapping(value = "/{id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) public String readClientConfiguration(@PathVariable("id") String clientId, Model m, OAuth2Authentication auth) { ClientDetailsEntity client = clientService.loadClientByClientId(clientId); if (client != null && client.getClientId().equals(auth.getOAuth2Request().getClientId())) { try { OAuth2AccessTokenEntity token = fetchValidRegistrationToken(auth, client); RegisteredClient registered = new RegisteredClient(client, token.getValue(), config.getIssuer() + "register/" + 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("readClientConfiguration 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.REGISTRATION_TOKEN_SCOPE + "')") @RequestMapping(value = "/{id}", method = RequestMethod.PUT, produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) public String updateClient(@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("updateClient 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()); // we need to copy over all of the local and SECOAUTH fields newClient.setAccessTokenValiditySeconds(oldClient.getAccessTokenValiditySeconds()); newClient.setIdTokenValiditySeconds(oldClient.getIdTokenValiditySeconds()); newClient.setRefreshTokenValiditySeconds(oldClient.getRefreshTokenValiditySeconds()); newClient.setDynamicallyRegistered(true); // it's still dynamically registered newClient.setAllowIntrospection(false); // dynamically registered clients can't do introspection -- use the resource registration instead newClient.setAuthorities(oldClient.getAuthorities()); newClient.setClientDescription(oldClient.getClientDescription()); newClient.setCreatedAt(oldClient.getCreatedAt()); newClient.setReuseRefreshToken(oldClient.isReuseRefreshToken()); // do validation on the fields try { newClient = validateScopes(newClient); newClient = validateResponseTypes(newClient); newClient = validateGrantTypes(newClient); newClient = validateRedirectUris(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); OAuth2AccessTokenEntity token = fetchValidRegistrationToken(auth, savedClient); RegisteredClient registered = new RegisteredClient(savedClient, token.getValue(), config.getIssuer() + "register/" + 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("updateClient 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.REGISTRATION_TOKEN_SCOPE + "')") @RequestMapping(value = "/{id}", method = RequestMethod.DELETE, produces = MediaType.APPLICATION_JSON_VALUE) public String deleteClient(@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 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; } private ClientDetailsEntity validateResponseTypes(ClientDetailsEntity newClient) throws ValidationException { if (newClient.getResponseTypes() == null) { newClient.setResponseTypes(new HashSet<String>()); } return newClient; } private ClientDetailsEntity validateGrantTypes(ClientDetailsEntity newClient) throws ValidationException { // set default grant types if needed if (newClient.getGrantTypes() == null || newClient.getGrantTypes().isEmpty()) { if (newClient.getScope().contains("offline_access")) { // client asked for offline access newClient.setGrantTypes(Sets.newHashSet("authorization_code", "refresh_token")); // allow authorization code and refresh token grant types by default } else { newClient.setGrantTypes(Sets.newHashSet("authorization_code")); // allow authorization code grant type by default } if (config.isDualClient()) { Set<String> extendedGrandTypes = newClient.getGrantTypes(); extendedGrandTypes.add("client_credentials"); newClient.setGrantTypes(extendedGrandTypes); } } // filter out unknown grant types // TODO: make this a pluggable service Set<String> requestedGrantTypes = new HashSet<>(newClient.getGrantTypes()); requestedGrantTypes.retainAll(ImmutableSet.of("authorization_code", "implicit", "password", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant_type:redelegate")); // don't allow "password" grant type for dynamic registration if (newClient.getGrantTypes().contains("password")) { // return an error, you can't dynamically register for the password grant throw new ValidationException("invalid_client_metadata", "The password grant type is not allowed in dynamic registration on this server.", HttpStatus.BAD_REQUEST); } // don't allow clients to have multiple incompatible grant types and scopes if (newClient.getGrantTypes().contains("authorization_code")) { // check for incompatible grants if (newClient.getGrantTypes().contains("implicit") || (!config.isDualClient() && newClient.getGrantTypes().contains("client_credentials"))) { // return an error, you can't have these grant types together throw new ValidationException("invalid_client_metadata", "Incompatible grant types requested: " + newClient.getGrantTypes(), HttpStatus.BAD_REQUEST); } if (newClient.getResponseTypes().contains("token")) { // return an error, you can't have this grant type and response type together throw new ValidationException( "invalid_client_metadata", "Incompatible response types requested: " + newClient.getGrantTypes() + " / " + newClient.getResponseTypes(), HttpStatus.BAD_REQUEST); } newClient.getResponseTypes().add("code"); } if (newClient.getGrantTypes().contains("implicit")) { // check for incompatible grants if (newClient.getGrantTypes().contains("authorization_code") || (!config.isDualClient() && newClient.getGrantTypes().contains("client_credentials"))) { // return an error, you can't have these grant types together throw new ValidationException("invalid_client_metadata", "Incompatible grant types requested: " + newClient.getGrantTypes(), HttpStatus.BAD_REQUEST); } if (newClient.getResponseTypes().contains("code")) { // return an error, you can't have this grant type and response type together throw new ValidationException( "invalid_client_metadata", "Incompatible response types requested: " + newClient.getGrantTypes() + " / " + newClient.getResponseTypes(), HttpStatus.BAD_REQUEST); } newClient.getResponseTypes().add("token"); // don't allow refresh tokens in implicit clients newClient.getGrantTypes().remove("refresh_token"); newClient.getScope().remove(SystemScopeService.OFFLINE_ACCESS); } if (newClient.getGrantTypes().contains("client_credentials")) { // check for incompatible grants if (!config.isDualClient() && (newClient.getGrantTypes().contains("authorization_code") || newClient.getGrantTypes().contains("implicit"))) { // return an error, you can't have these grant types together throw new ValidationException("invalid_client_metadata", "Incompatible grant types requested: " + newClient.getGrantTypes(), HttpStatus.BAD_REQUEST); } if (!newClient.getResponseTypes().isEmpty()) { // return an error, you can't have this grant type and response type together throw new ValidationException( "invalid_client_metadata", "Incompatible response types requested: " + newClient.getGrantTypes() + " / " + newClient.getResponseTypes(), HttpStatus.BAD_REQUEST); } // don't allow refresh tokens or id tokens in client_credentials clients newClient.getGrantTypes().remove("refresh_token"); newClient.getScope().remove(SystemScopeService.OFFLINE_ACCESS); newClient.getScope().remove(SystemScopeService.OPENID_SCOPE); } if (newClient.getGrantTypes().isEmpty()) { // return an error, you need at least one grant type selected throw new ValidationException("invalid_client_metadata", "Clients must register at least one grant type.", HttpStatus.BAD_REQUEST); } return newClient; } private ClientDetailsEntity validateRedirectUris(ClientDetailsEntity newClient) throws ValidationException { // check to make sure this client registered a redirect URI if using a redirect flow if (newClient.getGrantTypes().contains("authorization_code") || newClient.getGrantTypes().contains("implicit")) { if (newClient.getRedirectUris() == null || newClient.getRedirectUris().isEmpty()) { // return an error throw new ValidationException("invalid_redirect_uri", "Clients using a redirect-based grant type must register at least one redirect URI.", HttpStatus.BAD_REQUEST); } for (String uri : newClient.getRedirectUris()) { if (blacklistService.isBlacklisted(uri)) { // return an error throw new ValidationException("invalid_redirect_uri", "Redirect URI is not allowed: " + uri, HttpStatus.BAD_REQUEST); } if (uri.contains("#")) { // if it contains the hash symbol then it has a fragment, which isn't allowed throw new ValidationException("invalid_redirect_uri", "Redirect URI can not have a fragment", HttpStatus.BAD_REQUEST); } } } return newClient; } 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.createRegistrationAccessToken(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; } } }