From 044157061fcf726e51bbc1cb2b981aa8c7ec9087 Mon Sep 17 00:00:00 2001 From: Vincent Boulaye Date: Tue, 20 Jul 2021 23:05:17 +0200 Subject: [PATCH] Enable customizing headers in token requests Adds the possibility to customize the headers of the access token request in AbstractWebClientReactiveOAuth2AccessTokenResponseClient, similarly to what is done in the AbstractOAuth2AuthorizationGrantRequestEntityConverter. Closes gh-10130 --- ...activeOAuth2AccessTokenResponseClient.java | 67 +++++++++++++++++- ...orizationCodeTokenResponseClientTests.java | 66 +++++++++++++++++- ...ntCredentialsTokenResponseClientTests.java | 62 +++++++++++++++++ ...ctivePasswordTokenResponseClientTests.java | 69 ++++++++++++++++++- ...eRefreshTokenTokenResponseClientTests.java | 68 +++++++++++++++++- 5 files changed, 326 insertions(+), 6 deletions(-) diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractWebClientReactiveOAuth2AccessTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractWebClientReactiveOAuth2AccessTokenResponseClient.java index 68b0818ace..efc22ae7c6 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractWebClientReactiveOAuth2AccessTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractWebClientReactiveOAuth2AccessTokenResponseClient.java @@ -24,6 +24,7 @@ import java.util.Set; import reactor.core.publisher.Mono; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -65,6 +66,8 @@ public abstract class AbstractWebClientReactiveOAuth2AccessTokenResponseClient headersConverter = this::populateTokenRequestHeaders; + AbstractWebClientReactiveOAuth2AccessTokenResponseClient() { } @@ -74,7 +77,12 @@ public abstract class AbstractWebClientReactiveOAuth2AccessTokenResponseClient this.webClient.post() .uri(clientRegistration(grantRequest).getProviderDetails().getTokenUri()) - .headers((headers) -> populateTokenRequestHeaders(grantRequest, headers)) + .headers((headers) -> { + HttpHeaders headersToAdd = getHeadersConverter().convert(grantRequest); + if (headersToAdd != null) { + headers.addAll(headersToAdd); + } + }) .body(createTokenRequestBody(grantRequest)) .exchange() .flatMap((response) -> readTokenResponse(grantRequest, response)) @@ -92,9 +100,10 @@ public abstract class AbstractWebClientReactiveOAuth2AccessTokenResponseClient getHeadersConverter() { + return this.headersConverter; + } + + /** + * Sets the {@link Converter} used for converting the + * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link HttpHeaders} + * used in the OAuth 2.0 Access Token Request headers. + * @param headersConverter the {@link Converter} used for converting the + * {@link AbstractOAuth2AuthorizationGrantRequest} to {@link HttpHeaders} + * @since 5.6 + */ + public final void setHeadersConverter(Converter headersConverter) { + Assert.notNull(headersConverter, "headersConverter cannot be null"); + this.headersConverter = headersConverter; + } + + /** + * Add (compose) the provided {@code headersConverter} to the current + * {@link Converter} used for converting the + * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link HttpHeaders} + * used in the OAuth 2.0 Access Token Request headers. + * @param headersConverter the {@link Converter} to add (compose) to the current + * {@link Converter} used for converting the + * {@link AbstractOAuth2AuthorizationGrantRequest} to a {@link HttpHeaders} + * @since 5.6 + */ + public final void addHeadersConverter(Converter headersConverter) { + Assert.notNull(headersConverter, "headersConverter cannot be null"); + Converter currentHeadersConverter = this.headersConverter; + this.headersConverter = (authorizationGrantRequest) -> { + // Append headers using a Composite Converter + HttpHeaders headers = currentHeadersConverter.convert(authorizationGrantRequest); + if (headers == null) { + headers = new HttpHeaders(); + } + HttpHeaders headersToAdd = headersConverter.convert(authorizationGrantRequest); + if (headersToAdd != null) { + headers.addAll(headersToAdd); + } + return headers; + }; + } + } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveAuthorizationCodeTokenResponseClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveAuthorizationCodeTokenResponseClientTests.java index 8d39992d77..af93ec27c2 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveAuthorizationCodeTokenResponseClientTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveAuthorizationCodeTokenResponseClientTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,15 +17,18 @@ package org.springframework.security.oauth2.client.endpoint; import java.time.Instant; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -340,4 +343,65 @@ public class WebClientReactiveAuthorizationCodeTokenResponseClientTests { return new OAuth2AuthorizationCodeGrantRequest(registration, authorizationExchange); } + @Test + public void setHeadersConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.setHeadersConverter(null)) + .withMessage("headersConverter cannot be null"); + } + + @Test + public void addHeadersConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.addHeadersConverter(null)) + .withMessage("headersConverter cannot be null"); + } + + @Test + public void convertWhenHeadersConverterAddedThenCalled() throws Exception { + OAuth2AuthorizationCodeGrantRequest request = authorizationCodeGrantRequest(); + Converter addedHeadersConverter = mock(Converter.class); + final HttpHeaders headers = new HttpHeaders(); + headers.put("CUSTOM_AUTHORIZATION", Collections.singletonList("Basic CUSTOM")); + given(addedHeadersConverter.convert(request)).willReturn(headers); + this.tokenResponseClient.addHeadersConverter(addedHeadersConverter); + // @formatter:off + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"openid profile\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + this.tokenResponseClient.getTokenResponse(request).block(); + verify(addedHeadersConverter).convert(request); + RecordedRequest actualRequest = this.server.takeRequest(); + assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Basic Y2xpZW50LWlkOmNsaWVudC1zZWNyZXQ="); + assertThat(actualRequest.getHeader("CUSTOM_AUTHORIZATION")).isEqualTo("Basic CUSTOM"); + } + + @Test + public void convertWhenHeadersConverterSetThenCalled() throws Exception { + OAuth2AuthorizationCodeGrantRequest request = authorizationCodeGrantRequest(); + Converter headersConverter = mock(Converter.class); + final HttpHeaders headers = new HttpHeaders(); + headers.put(HttpHeaders.AUTHORIZATION, Collections.singletonList("Basic CUSTOM")); + given(headersConverter.convert(request)).willReturn(headers); + this.tokenResponseClient.setHeadersConverter(headersConverter); + // @formatter:off + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"openid profile\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + this.tokenResponseClient.getTokenResponse(request).block(); + verify(headersConverter).convert(request); + RecordedRequest actualRequest = this.server.takeRequest(); + assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("Basic CUSTOM"); + + } + } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClientTests.java index dba3afb8e9..a3462449c9 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClientTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClientTests.java @@ -19,6 +19,7 @@ package org.springframework.security.oauth2.client.endpoint; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.Collections; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -27,6 +28,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -212,4 +214,64 @@ public class WebClientReactiveClientCredentialsTokenResponseClientTests { this.server.enqueue(response); } + @Test + public void setHeadersConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.client.setHeadersConverter(null)) + .withMessage("headersConverter cannot be null"); + } + + @Test + public void addHeadersConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.client.addHeadersConverter(null)) + .withMessage("headersConverter cannot be null"); + } + + @Test + public void convertWhenHeadersConverterAddedThenCalled() throws Exception { + OAuth2ClientCredentialsGrantRequest request = new OAuth2ClientCredentialsGrantRequest( + this.clientRegistration.build()); + Converter addedHeadersConverter = mock(Converter.class); + final HttpHeaders headers = new HttpHeaders(); + headers.put("CUSTOM_AUTHORIZATION", Collections.singletonList("Basic CUSTOM")); + given(addedHeadersConverter.convert(request)).willReturn(headers); + this.client.addHeadersConverter(addedHeadersConverter); + // @formatter:off + enqueueJson("{\n" + + " \"access_token\":\"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3\",\n" + + " \"token_type\":\"bearer\",\n" + + " \"expires_in\":3600,\n" + + " \"refresh_token\":\"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk\"\n" + + "}"); + // @formatter:on + this.client.getTokenResponse(request).block(); + verify(addedHeadersConverter).convert(request); + RecordedRequest actualRequest = this.server.takeRequest(); + assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Basic Y2xpZW50LWlkOmNsaWVudC1zZWNyZXQ="); + assertThat(actualRequest.getHeader("CUSTOM_AUTHORIZATION")).isEqualTo("Basic CUSTOM"); + } + + @Test + public void convertWhenHeadersConverterSetThenCalled() throws Exception { + OAuth2ClientCredentialsGrantRequest request = new OAuth2ClientCredentialsGrantRequest( + this.clientRegistration.build()); + Converter headersConverter = mock(Converter.class); + final HttpHeaders headers = new HttpHeaders(); + headers.put(HttpHeaders.AUTHORIZATION, Collections.singletonList("Basic CUSTOM")); + given(headersConverter.convert(request)).willReturn(headers); + this.client.setHeadersConverter(headersConverter); + // @formatter:off + enqueueJson("{\n" + + " \"access_token\":\"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3\",\n" + + " \"token_type\":\"bearer\",\n" + + " \"expires_in\":3600,\n" + + " \"refresh_token\":\"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk\"\n" + + "}"); + // @formatter:on + this.client.getTokenResponse(request).block(); + verify(headersConverter).convert(request); + RecordedRequest actualRequest = this.server.takeRequest(); + assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("Basic CUSTOM"); + } + } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactivePasswordTokenResponseClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactivePasswordTokenResponseClientTests.java index 5595905522..848f4cd609 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactivePasswordTokenResponseClientTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactivePasswordTokenResponseClientTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package org.springframework.security.oauth2.client.endpoint; import java.time.Instant; +import java.util.Collections; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -25,6 +26,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -38,6 +40,9 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenRespon import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; /** * Tests for {@link WebClientReactivePasswordTokenResponseClient}. @@ -213,4 +218,66 @@ public class WebClientReactivePasswordTokenResponseClientTests { // @formatter:on } + @Test + public void setHeadersConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.setHeadersConverter(null)) + .withMessage("headersConverter cannot be null"); + } + + @Test + public void addHeadersConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.addHeadersConverter(null)) + .withMessage("headersConverter cannot be null"); + } + + @Test + public void convertWhenHeadersConverterAddedThenCalled() throws Exception { + OAuth2PasswordGrantRequest request = new OAuth2PasswordGrantRequest(this.clientRegistrationBuilder.build(), + this.username, this.password); + Converter addedHeadersConverter = mock(Converter.class); + final HttpHeaders headers = new HttpHeaders(); + headers.put("CUSTOM_AUTHORIZATION", Collections.singletonList("Basic CUSTOM")); + given(addedHeadersConverter.convert(request)).willReturn(headers); + this.tokenResponseClient.addHeadersConverter(addedHeadersConverter); + // @formatter:off + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"read\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + this.tokenResponseClient.getTokenResponse(request).block(); + verify(addedHeadersConverter).convert(request); + RecordedRequest actualRequest = this.server.takeRequest(); + assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Basic Y2xpZW50LWlkOmNsaWVudC1zZWNyZXQ="); + assertThat(actualRequest.getHeader("CUSTOM_AUTHORIZATION")).isEqualTo("Basic CUSTOM"); + } + + @Test + public void convertWhenHeadersConverterSetThenCalled() throws Exception { + OAuth2PasswordGrantRequest request = new OAuth2PasswordGrantRequest(this.clientRegistrationBuilder.build(), + this.username, this.password); + Converter headersConverter = mock(Converter.class); + final HttpHeaders headers = new HttpHeaders(); + headers.put(HttpHeaders.AUTHORIZATION, Collections.singletonList("Basic CUSTOM")); + given(headersConverter.convert(request)).willReturn(headers); + this.tokenResponseClient.setHeadersConverter(headersConverter); + // @formatter:off + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"read\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + this.tokenResponseClient.getTokenResponse(request).block(); + verify(headersConverter).convert(request); + RecordedRequest actualRequest = this.server.takeRequest(); + assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("Basic CUSTOM"); + } + } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClientTests.java index 5cea289645..9eb2e665f5 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClientTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClientTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -42,6 +43,9 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenRespon import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; /** * Tests for {@link WebClientReactiveRefreshTokenTokenResponseClient}. @@ -217,4 +221,66 @@ public class WebClientReactiveRefreshTokenTokenResponseClientTests { // @formatter:on } + @Test + public void setHeadersConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.setHeadersConverter(null)) + .withMessage("headersConverter cannot be null"); + } + + @Test + public void addHeadersConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.addHeadersConverter(null)) + .withMessage("headersConverter cannot be null"); + } + + @Test + public void convertWhenHeadersConverterAddedThenCalled() throws Exception { + OAuth2RefreshTokenGrantRequest request = new OAuth2RefreshTokenGrantRequest( + this.clientRegistrationBuilder.build(), this.accessToken, this.refreshToken); + Converter addedHeadersConverter = mock(Converter.class); + final HttpHeaders headers = new HttpHeaders(); + headers.put("CUSTOM_AUTHORIZATION", Collections.singletonList("Basic CUSTOM")); + given(addedHeadersConverter.convert(request)).willReturn(headers); + this.tokenResponseClient.addHeadersConverter(addedHeadersConverter); + // @formatter:off + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"read\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + this.tokenResponseClient.getTokenResponse(request).block(); + verify(addedHeadersConverter).convert(request); + RecordedRequest actualRequest = this.server.takeRequest(); + assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Basic Y2xpZW50LWlkOmNsaWVudC1zZWNyZXQ="); + assertThat(actualRequest.getHeader("CUSTOM_AUTHORIZATION")).isEqualTo("Basic CUSTOM"); + } + + @Test + public void convertWhenHeadersConverterSetThenCalled() throws Exception { + OAuth2RefreshTokenGrantRequest request = new OAuth2RefreshTokenGrantRequest( + this.clientRegistrationBuilder.build(), this.accessToken, this.refreshToken); + Converter headersConverter1 = mock(Converter.class); + final HttpHeaders headers = new HttpHeaders(); + headers.put(HttpHeaders.AUTHORIZATION, Collections.singletonList("Basic CUSTOM")); + given(headersConverter1.convert(request)).willReturn(headers); + this.tokenResponseClient.setHeadersConverter(headersConverter1); + // @formatter:off + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"read\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + this.tokenResponseClient.getTokenResponse(request).block(); + verify(headersConverter1).convert(request); + RecordedRequest actualRequest = this.server.takeRequest(); + assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("Basic CUSTOM"); + } + }