package org.cloudfoundry.identity.uaa.mock.clients; import org.apache.commons.lang.ArrayUtils; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsModification; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter; import org.junit.Before; import org.junit.Test; import org.springframework.restdocs.headers.HeaderDescriptor; import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.restdocs.snippet.Snippet; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.common.util.RandomValueStringGenerator; import org.springframework.security.oauth2.provider.ClientDetails; import org.springframework.security.oauth2.provider.client.BaseClientDetails; import org.springframework.test.web.servlet.ResultActions; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import static org.cloudfoundry.identity.uaa.oauth.client.SecretChangeRequest.ChangeMode.ADD; import static org.cloudfoundry.identity.uaa.oauth.client.SecretChangeRequest.ChangeMode.DELETE; import static org.cloudfoundry.identity.uaa.oauth.client.SecretChangeRequest.ChangeMode.UPDATE; import static org.cloudfoundry.identity.uaa.test.SnippetUtils.fieldWithPath; import static org.cloudfoundry.identity.uaa.test.SnippetUtils.parameterWithName; import static org.cloudfoundry.identity.uaa.test.SnippetUtils.subFields; import static org.cloudfoundry.identity.uaa.util.JsonUtils.serializeExcludingProperties; import static org.cloudfoundry.identity.uaa.util.JsonUtils.writeValueAsString; import static org.cloudfoundry.identity.uaa.util.UaaMapUtils.entry; import static org.cloudfoundry.identity.uaa.util.UaaMapUtils.map; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.payload.JsonFieldType.*; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; public class ClientAdminEndpointsDocs extends AdminClientCreator { private String clientAdminToken; private static final FieldDescriptor clientSecretField = fieldWithPath("client_secret").constrained("Required if the client allows `authorization_code` or `client_credentials` grant type").type(STRING).description("A secret string used for authenticating as this client. To support secret rotation this can be space delimited string of two secrets."); private static final FieldDescriptor actionField = fieldWithPath("action").constrained("Always required.").description("Set to `secret` to change client secret, `delete` to delete the client or `add` to add the client"); private static final HeaderDescriptor authorizationHeader = headerWithName("Authorization").description("Bearer token containing `clients.write`, `clients.admin` or `zones.{zone.id}.admin`"); private static final HeaderDescriptor IDENTITY_ZONE_ID_HEADER = headerWithName(IdentityZoneSwitchingFilter.HEADER).optional().description("If using a `zones.<zoneId>.admin scope/token, indicates what zone this request goes to by supplying a zone_id."); private static final HeaderDescriptor IDENTITY_ZONE_SUBDOMAIN_HEADER = headerWithName(IdentityZoneSwitchingFilter.SUBDOMAIN_HEADER).optional().description("If using a `zones.<zoneId>.admin scope/token, indicates what zone this request goes to by supplying a subdomain."); private static final FieldDescriptor lastModifiedField = fieldWithPath("lastModified").description("Epoch of the moment the client information was last altered"); private static final String clientIdDescription = "Client identifier, unique within identity zone"; private static final FieldDescriptor[] idempotentFields = new FieldDescriptor[]{ fieldWithPath("client_id").required().description(clientIdDescription), fieldWithPath("authorized_grant_types").required().description("List of grant types that can be used to obtain a token with this client. Can include `authorization_code`, `password`, `implicit`, and/or `client_credentials`."), fieldWithPath("redirect_uri").required().type(ARRAY).description("Allowed URI pattern for redirect during authorization. Wildcard patterns can be specified using the Ant-style pattern. Null/Empty value is forbidden."), fieldWithPath("scope").optional("uaa.none").type(ARRAY).description("Scopes allowed for the client"), fieldWithPath("resource_ids").optional(Collections.emptySet()).type(ARRAY).description("Resources the client is allowed access to"), fieldWithPath("authorities").optional("uaa.none").type(ARRAY).description("Scopes which the client is able to grant when creating a client"), fieldWithPath("autoapprove").optional(Collections.emptySet()).type(Arrays.asList(BOOLEAN, ARRAY)).description("Scopes that do not require user approval"), fieldWithPath("access_token_validity").optional(null).type(NUMBER).description("time in seconds to access token expiration after it is issued"), fieldWithPath("refresh_token_validity").optional(null).type(NUMBER).description("time in seconds to refresh token expiration after it is issued"), fieldWithPath(ClientConstants.ALLOWED_PROVIDERS).optional(null).type(ARRAY).description("A list of origin keys (alias) for identity providers the client is limited to. Null implies any identity provider is allowed."), fieldWithPath(ClientConstants.CLIENT_NAME).optional(null).type(STRING).description("A human readable name for the client"), fieldWithPath(ClientConstants.TOKEN_SALT).optional(null).type(STRING).description("A random string used to generate the client's revokation key. Change this value to revoke all active tokens for the client"), fieldWithPath(ClientConstants.CREATED_WITH).optional(null).type(STRING).description("What scope the bearer token had when client was created"), fieldWithPath(ClientConstants.APPROVALS_DELETED).optional(null).type(BOOLEAN).description("Were the approvals deleted for the client, and an audit event sent"), fieldWithPath(ClientConstants.REQUIRED_USER_GROUPS).optional(null).type(ARRAY).description("A list of group names. If a user doesn't belong to all the required groups no tokens will be issued to this client for that user, regardless what scopes are being requested."), }; private static final FieldDescriptor[] secretChangeFields = new FieldDescriptor[]{ fieldWithPath("clientId").required().description(clientIdDescription), fieldWithPath("oldSecret").constrained("Optional if authenticated as an admin client. Required otherwise.").type(STRING).description("A valid client secret before updating"), fieldWithPath("secret").required().description("The new client secret"), fieldWithPath("changeMode").optional(UPDATE).type(STRING).description("If change mode is set to `"+ADD+"`, the new `secret` will be added to the existing one and if the change mode is set to `"+DELETE+"`, the old secret will be deleted to support secret rotation. Currently only two client secrets are supported at any given time.") }; @Before public void setup() throws Exception { clientAdminToken = testClient.getClientCredentialsOAuthAccessToken( "admin", "adminsecret", "uaa.admin clients.admin clients.secret"); } @Test public void createClient() throws Exception { Snippet requestFields = requestFields( (FieldDescriptor[]) ArrayUtils.addAll(idempotentFields, new FieldDescriptor[]{clientSecretField} )); Snippet responseFields = responseFields( (FieldDescriptor[]) ArrayUtils.addAll(idempotentFields, new FieldDescriptor[]{ lastModifiedField } )); ResultActions resultActions = createClientHelper(); resultActions.andDo(document("{ClassName}/{methodName}", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( authorizationHeader, IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), requestFields, responseFields )); } @Test public void listClients() throws Exception { ClientDetails createdClientDetails = JsonUtils.readValue(createClientHelper().andReturn().getResponse().getContentAsString(), BaseClientDetails.class); ResultActions resultActions = getMockMvc().perform(get("/oauth/clients") .header("Authorization", "Bearer " + clientAdminToken) .param("filter", String.format("client_id eq \"%s\"", createdClientDetails.getClientId())) .param("sortBy", "client_id") .param("sortOrder", "descending") .param("startIndex", "1") .param("count", "10") .accept(APPLICATION_JSON)); Snippet requestParameters = requestParameters( parameterWithName("filter").optional("client_id pr").type(STRING).description("SCIM filter for querying clients"), parameterWithName("sortBy").optional("client_id").type(STRING).description("Field to sort results by"), parameterWithName("sortOrder").optional("ascending").type(STRING).description("Sort results in `ascending` or `descending` order"), parameterWithName("startIndex").optional("1").type(NUMBER).description("Index of the first result on which to begin the page"), parameterWithName("count").optional("100").type(NUMBER).description("Number of results per page") ); Snippet responseFields = responseFields( (FieldDescriptor[]) ArrayUtils.addAll( subFields("resources[]", (FieldDescriptor[]) ArrayUtils.addAll(idempotentFields, new FieldDescriptor[]{lastModifiedField})), new FieldDescriptor[]{ fieldWithPath("startIndex").description("Index of the first result on this page"), fieldWithPath("itemsPerPage").description("Number of results per page"), fieldWithPath("totalResults").description("Total number of results that matched the query"), fieldWithPath("schemas").description("`[\"urn:scim:schemas:core:1.0\"]`") } ) ); resultActions.andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("Bearer token containing `clients.read`, `clients.admin` or `zones.{zone.id}.admin`"), IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), requestParameters, responseFields )); } @Test public void retrieveClient() throws Exception { ClientDetails createdClientDetails = JsonUtils.readValue(createClientHelper().andReturn().getResponse().getContentAsString(), BaseClientDetails.class); ResultActions resultActions = getMockMvc().perform(get("/oauth/clients/{client_id}", createdClientDetails.getClientId()) .header("Authorization", "Bearer " + clientAdminToken) .accept(APPLICATION_JSON) ); resultActions.andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), pathParameters( parameterWithName("client_id").required().description(clientIdDescription) ), requestHeaders( headerWithName("Authorization").description("Bearer token containing `clients.read`, `clients.admin` or `zones.{zone.id}.admin`"), IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), responseFields( (FieldDescriptor[]) ArrayUtils.addAll(idempotentFields, new FieldDescriptor[]{ lastModifiedField } ) ) )); } @Test public void updateClient() throws Exception { ClientDetails createdClientDetails = JsonUtils.readValue(createClientHelper().andReturn().getResponse().getContentAsString(), BaseClientDetails.class); BaseClientDetails updatedClientDetails = new BaseClientDetails(); updatedClientDetails.setClientId(createdClientDetails.getClientId()); updatedClientDetails.setScope(Arrays.asList("clients.new", "clients.autoapprove")); updatedClientDetails.setAutoApproveScopes(Arrays.asList("clients.autoapprove")); updatedClientDetails.setAuthorizedGrantTypes(createdClientDetails.getAuthorizedGrantTypes()); updatedClientDetails.setRegisteredRedirectUri(Collections.singleton("http://redirect.url")); ResultActions resultActions = getMockMvc().perform(put("/oauth/clients/{client_id}", createdClientDetails.getClientId()) .header("Authorization", "Bearer " + clientAdminToken) .contentType(APPLICATION_JSON) .accept(APPLICATION_JSON) .content(writeValueAsString(updatedClientDetails))) .andExpect(status().isOk()); Snippet requestFields = requestFields(idempotentFields); Snippet responseFields = responseFields((FieldDescriptor[]) ArrayUtils.addAll(idempotentFields, new FieldDescriptor[]{ lastModifiedField } ) ); resultActions.andDo(document("{ClassName}/{methodName}", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("client_id").required().description(clientIdDescription) ), requestHeaders( authorizationHeader, IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), requestFields, responseFields) ); } @Test public void changeClientSecret() throws Exception { ClientDetails createdClientDetails = JsonUtils.readValue(createClientHelper().andReturn().getResponse().getContentAsString(), BaseClientDetails.class); ResultActions resultActions = getMockMvc().perform(put("/oauth/clients/{client_id}/secret", createdClientDetails.getClientId()) .header("Authorization", "Bearer " + clientAdminToken) .contentType(APPLICATION_JSON) .accept(APPLICATION_JSON) .content(writeValueAsString(map( entry("clientId", createdClientDetails.getClientId()), entry("secret", "new_secret") )))) .andExpect(status().isOk()); resultActions.andDo(document("{ClassName}/{methodName}", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("client_id").required().description(clientIdDescription) ), requestHeaders( authorizationHeader, IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), requestFields(secretChangeFields) ) ); } @Test public void deleteClient() throws Exception { ClientDetails createdClientDetails = JsonUtils.readValue(createClientHelper().andReturn().getResponse().getContentAsString(), BaseClientDetails.class); ResultActions resultActions = getMockMvc().perform(delete("/oauth/clients/{client_id}", createdClientDetails.getClientId()) .header("Authorization", "Bearer " + clientAdminToken) .accept(APPLICATION_JSON)); resultActions.andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), pathParameters( parameterWithName("client_id").required().description(clientIdDescription) ), requestHeaders( authorizationHeader, IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), responseFields((FieldDescriptor[]) ArrayUtils.addAll(idempotentFields, new FieldDescriptor[]{ lastModifiedField } ))) ); } @Test public void clientTx() throws Exception { // CREATE List<String> scopes = Arrays.asList("clients.read", "clients.write"); BaseClientDetails createdClientDetails1 = createBasicClientWithAdditionalInformation(scopes); BaseClientDetails createdClientDetails2 = createBasicClientWithAdditionalInformation(scopes); ResultActions createResultActions = getMockMvc().perform(post("/oauth/clients/tx") .contentType(APPLICATION_JSON) .content(JsonUtils.writeValueAsString(Arrays.asList(createdClientDetails1, createdClientDetails2))) .header("Authorization", "Bearer " + clientAdminToken) .accept(APPLICATION_JSON)); FieldDescriptor[] fieldsNoSecret = subFields("[]", idempotentFields); FieldDescriptor[] fieldsWithSecret = (FieldDescriptor[]) ArrayUtils.addAll( fieldsNoSecret, subFields("[]", clientSecretField) ); FieldDescriptor[] fieldsWithSecretAndAction = (FieldDescriptor[]) ArrayUtils.addAll( fieldsWithSecret, subFields("[]", actionField) ); Snippet responseFields = responseFields((FieldDescriptor[]) ArrayUtils.addAll( fieldsNoSecret, subFields("[]", lastModifiedField) )); Snippet responseFieldsWithAction = responseFields((FieldDescriptor[]) ArrayUtils.addAll( fieldsNoSecret, subFields("[]", lastModifiedField, actionField) )); createResultActions .andExpect(status().isCreated()) .andDo(document("{ClassName}/createClientTx", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( authorizationHeader, IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), requestFields(fieldsWithSecret), responseFields ) ); //UPDATE createdClientDetails1.setRegisteredRedirectUri(Collections.singleton("http://updated.redirect.uri/")); createdClientDetails2.getAuthorities().add(new SimpleGrantedAuthority("new.authority")); ResultActions updateResultActions = getMockMvc().perform(put("/oauth/clients/tx") .contentType(APPLICATION_JSON) .content("[" + serializeExcludingProperties(createdClientDetails1, "client_secret", "lastModified") + "," + serializeExcludingProperties(createdClientDetails2, "client_secret", "lastModified") + "]") .header("Authorization", "Bearer " + clientAdminToken) .accept(APPLICATION_JSON)); updateResultActions .andExpect(status().isOk()) .andDo(document("{ClassName}/updateClientTx", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( authorizationHeader, IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), requestFields(fieldsNoSecret), responseFields ) ); // CHANGE SECRET Map<String, Object> client1SecretChange = map( entry("clientId", createdClientDetails1.getClientId()), entry("secret", "new_secret") ); Map<String, Object> client2SecretChange = map( entry("clientId", createdClientDetails2.getClientId()), entry("secret", "new_secret") ); String content = JsonUtils.writeValueAsString(new Object[]{client1SecretChange, client2SecretChange}); ResultActions secretResultActions = getMockMvc().perform(post("/oauth/clients/tx/secret") .contentType(APPLICATION_JSON) .content(content) .header("Authorization", "Bearer " + clientAdminToken) .accept(APPLICATION_JSON)); secretResultActions .andExpect(status().isOk()) .andDo(document("{ClassName}/secretClientTx", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( authorizationHeader, IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), requestFields(subFields("[]", secretChangeFields)), responseFields((FieldDescriptor[]) ArrayUtils.addAll( fieldsNoSecret, subFields("[]", lastModifiedField, fieldWithPath("approvals_deleted").description("Indicates whether the approvals associated with the client were deleted as a result of this action") ) )) ) ); // BATCH Map<String, Object> modify1 = map( entry("action", ClientDetailsModification.SECRET), entry("client_id", createdClientDetails1.getClientId()), entry("client_secret", "new_secret") ); Map<String, Object> modify2 = map( entry("action", ClientDetailsModification.DELETE), entry("client_id", createdClientDetails2.getClientId()) ); BaseClientDetails createdClientDetails3 = createBasicClientWithAdditionalInformation(scopes); ClientDetailsModification modify3 = new ClientDetailsModification(createdClientDetails3); modify3.setAction(ClientDetailsModification.ADD); ResultActions modifyResultActions = getMockMvc().perform(post("/oauth/clients/tx/modify") .contentType(APPLICATION_JSON) .content(JsonUtils.writeValueAsString(new Object[]{modify1, modify2, modify3})) .header("Authorization", "Bearer " + clientAdminToken) .accept(APPLICATION_JSON)); modifyResultActions .andExpect(status().isOk()) .andDo(document("{ClassName}/modifyClientTx", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( authorizationHeader, IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER ), requestFields(fieldsWithSecretAndAction), responseFieldsWithAction ) ); //DELETE ResultActions deleteResultActions = getMockMvc().perform(post("/oauth/clients/tx/delete") .contentType(APPLICATION_JSON) .content("[{\"client_id\":\"" + createdClientDetails1.getClientId() + "\"},{\"client_id\":\"" + createdClientDetails3.getClientId() + "\"}]") .header("Authorization", "Bearer " + clientAdminToken) .accept(APPLICATION_JSON)); deleteResultActions .andExpect(status().isOk()) .andDo(document("{ClassName}/deleteClientTx", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders(authorizationHeader, IDENTITY_ZONE_ID_HEADER, IDENTITY_ZONE_SUBDOMAIN_HEADER), requestFields(fieldWithPath("[].client_id").required().description(clientIdDescription)), responseFields((FieldDescriptor[]) ArrayUtils.addAll( fieldsNoSecret, subFields("[]", lastModifiedField, fieldWithPath("approvals_deleted").description("Indicates whether the approvals associated with the client were deleted as a result of this action")) )) ) ); } private BaseClientDetails createBasicClientWithAdditionalInformation(List<String> scopes) { BaseClientDetails clientDetails = createBaseClient(null, null, scopes, scopes); clientDetails.setAdditionalInformation(additionalInfo()); return clientDetails; } private ResultActions createClientHelper() throws Exception { return getMockMvc().perform(post("/oauth/clients") .header("Authorization", "Bearer " + clientAdminToken) .contentType(APPLICATION_JSON) .accept(APPLICATION_JSON) .content(writeValueAsString( createBasicClientWithAdditionalInformation(Arrays.asList("clients.read", "clients.write")) ))) .andExpect(status().isCreated()); } private Map<String, Object> additionalInfo() { Map<String, Object> additionalInformation = new HashMap<>(); additionalInformation.put("redirect_uri", Arrays.asList("http://test1.com", "http://ant.path.wildcard/**/passback/*")); additionalInformation.put(ClientConstants.ALLOWED_PROVIDERS, Arrays.asList(OriginKeys.UAA, OriginKeys.LDAP, "my-saml-provider")); additionalInformation.put(ClientConstants.CLIENT_NAME, "My Client Name"); additionalInformation.put(ClientConstants.AUTO_APPROVE, true); additionalInformation.put(ClientConstants.TOKEN_SALT, new RandomValueStringGenerator().generate()); return additionalInformation; } }