diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java index 9f8651b5..ac4a0ba8 100644 --- a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java @@ -424,7 +424,16 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h // URL in return_to) and back. So delegating only the consent path covers both — the // login step is handled transparently inside DC. authorizationServerConfigurer.authorizationEndpoint(authorize -> authorize - .consentPage("/authorize/delegate")); + .consentPage("/authorize/delegate") + // #251: customer authorization never yields an administrative scope. + .authenticationProviders(providers -> providers.forEach(provider -> { + if (provider instanceof org.springframework.security.oauth2.server.authorization.authentication + .OAuth2AuthorizationCodeRequestAuthenticationProvider codeRequest) { + codeRequest.setAuthenticationValidator( + org.greenbuttonalliance.espi.authserver.grant.EspiGrantScopePolicy + .authorizationCodeValidator()); + } + }))); // Advertise the RFC 7591 endpoint (wired on its own chain, see clientRegistrationSecurityFilterChain) // in /.well-known/oauth-authorization-server. The stock clientRegistrationEndpoint(...) configurer @@ -448,6 +457,15 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h http.getSharedObject(org.springframework.context.ApplicationContext.class) .getBean(OAuth2AuthorizationService.class); authorizationServerConfigurer.tokenEndpoint(tokenEndpoint -> tokenEndpoint + // #251: client_credentials never yields an ESPI customer data (FB) scope. + .authenticationProviders(providers -> providers.forEach(provider -> { + if (provider instanceof org.springframework.security.oauth2.server.authorization.authentication + .OAuth2ClientCredentialsAuthenticationProvider clientCredentials) { + clientCredentials.setAuthenticationValidator( + org.greenbuttonalliance.espi.authserver.grant.EspiGrantScopePolicy + .clientCredentialsValidator()); + } + })) .accessTokenResponseHandler( new org.greenbuttonalliance.espi.authserver.service .EspiTokenResponseSuccessHandler(authorizationServiceForHandler))); diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java new file mode 100644 index 00000000..da012dea --- /dev/null +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java @@ -0,0 +1,162 @@ +/* + * + * Copyright (c) 2026 Green Button Alliance, Inc. + * + * + * 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.greenbuttonalliance.espi.authserver.grant; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationValidator; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationValidator; +import org.springframework.util.StringUtils; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Consumer; + +/** + * Which grant may yield which scopes (#251). Spring Authorization Server only checks that a requested + * scope is registered for the client, whatever the grant, so a client registered for both + * {@code client_credentials} and a customer Function Block scope could otherwise mint a customer-data + * token with no customer consent. ESPI keeps the token kinds apart, and both rules are allowlists that + * fail closed: + * + * Refresh tokens can only narrow the scopes of the grant they came from, so they need no rule here. + */ +public final class EspiGrantScopePolicy { + + /** Scope that renews an expired registration access token (ESPI {@code FB=36_40}). */ + public static final String REGISTRATION_RENEWAL_SCOPE = "FB=36_40"; + + /** The only scopes {@code client_credentials} may yield. Never granted through a customer's consent. */ + public static final Set ADMINISTRATIVE_SCOPES = Set.of( + "ThirdParty_Admin_Access", + "DataCustodian_Admin_Access", + "Upload_Admin_Access", + REGISTRATION_RENEWAL_SCOPE, + "authserver.admin", + "client.create"); + + /** Function Blocks of the registration-renewal scope, which a customer can never grant. */ + public static final Set RESERVED_FUNCTION_BLOCKS = Set.of(36, 40); + + /** Non-ESPI scopes a customer authorization request may carry. */ + private static final Set CUSTOMER_IDENTITY_SCOPES = Set.of("openid", "profile"); + + private EspiGrantScopePolicy() { + } + + /** True when {@code client_credentials} may yield the scope. */ + public static boolean isClientCredentialsScope(String scope) { + return ADMINISTRATIVE_SCOPES.contains(scope); + } + + /** True when customer authorization ({@code authorization_code}) may yield the scope. */ + public static boolean isAuthorizationCodeScope(String scope) { + if (CUSTOMER_IDENTITY_SCOPES.contains(scope)) { + return true; + } + return functionBlocks(scope) + .filter(fbs -> !fbs.isEmpty() && fbs.stream().noneMatch(RESERVED_FUNCTION_BLOCKS::contains)) + .isPresent(); + } + + /** + * The Function Blocks the Data Custodian would read from {@code scope} (mirrors {@code EspiScope.parse}), + * or empty when the scope cannot be parsed, which callers treat as not allowed. + */ + static Optional> functionBlocks(String scope) { + if (scope == null || scope.isBlank()) { + return Optional.empty(); + } + Set fbs = new TreeSet<>(); + for (String segment : scope.split(";")) { + String term = segment.trim(); + int eq = term.indexOf('='); + if (eq < 0 || !term.substring(0, eq).trim().toLowerCase(Locale.ROOT).equals("fb")) { + continue; + } + for (String token : term.substring(eq + 1).split("_")) { + String fb = token.trim(); + if (fb.isEmpty()) { + continue; + } + try { + fbs.add(Integer.parseInt(fb)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + } + return Optional.of(fbs); + } + + /** Spring's default client_credentials scope check, followed by the ESPI rule. */ + public static Consumer clientCredentialsValidator() { + return OAuth2ClientCredentialsAuthenticationValidator.DEFAULT_SCOPE_VALIDATOR + .andThen(EspiGrantScopePolicy::requireClientCredentialsScopes); + } + + /** Spring's default authorization request checks, followed by the ESPI rule. */ + public static Consumer authorizationCodeValidator() { + return new OAuth2AuthorizationCodeRequestAuthenticationValidator() + .andThen(EspiGrantScopePolicy::requireAuthorizationCodeScopes); + } + + static void requireClientCredentialsScopes(OAuth2ClientCredentialsAuthenticationContext context) { + OAuth2ClientCredentialsAuthenticationToken request = context.getAuthentication(); + if (!request.getScopes().stream().allMatch(EspiGrantScopePolicy::isClientCredentialsScope)) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, + "client_credentials grants only administrative scopes, never ESPI customer data scopes", null)); + } + } + + static void requireAuthorizationCodeScopes(OAuth2AuthorizationCodeRequestAuthenticationContext context) { + OAuth2AuthorizationCodeRequestAuthenticationToken request = context.getAuthentication(); + if (request.getScopes().stream().allMatch(EspiGrantScopePolicy::isAuthorizationCodeScope)) { + return; + } + // Same shape as Spring's own invalid_scope error: redirected back to the client's redirect URI. + String redirectUri = StringUtils.hasText(request.getRedirectUri()) + ? request.getRedirectUri() + : context.getRegisteredClient().getRedirectUris().iterator().next(); + OAuth2AuthorizationCodeRequestAuthenticationToken result = new OAuth2AuthorizationCodeRequestAuthenticationToken( + request.getAuthorizationUri(), request.getClientId(), (Authentication) request.getPrincipal(), + redirectUri, request.getState(), request.getScopes(), request.getAdditionalParameters()); + result.setAuthenticated(true); + throw new OAuth2AuthorizationCodeRequestAuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, + "OAuth 2.0 Parameter: scope (customer authorization grants only ESPI customer data scopes)", + null), result); + } +} diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java new file mode 100644 index 00000000..7667501b --- /dev/null +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java @@ -0,0 +1,161 @@ +/* + * + * Copyright (c) 2026 Green Button Alliance, Inc. + * + * + * 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.greenbuttonalliance.espi.authserver.grant; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; + +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * #251: each grant yields only its own kind of scope, even for a client registered for both kinds. + */ +@DisplayName("ESPI per-grant scope policy (#251)") +class EspiGrantScopePolicyTest { + + private static final String CUSTOMER_SCOPE = "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13"; + + /** A client registered for both grants and both kinds of scope: the case Spring alone would allow. */ + private static final RegisteredClient CLIENT = RegisteredClient.withId("id-1") + .clientId("tp-1") + .clientSecret("{noop}x") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .redirectUri("https://tp.example/cb") + .scope(CUSTOMER_SCOPE) + .scope("ThirdParty_Admin_Access") + .scope(EspiGrantScopePolicy.REGISTRATION_RENEWAL_SCOPE) + .build(); + + private static OAuth2ClientCredentialsAuthenticationContext clientCredentials(Set scopes) { + OAuth2ClientAuthenticationToken principal = + new OAuth2ClientAuthenticationToken(CLIENT, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "x"); + return OAuth2ClientCredentialsAuthenticationContext + .with(new OAuth2ClientCredentialsAuthenticationToken(principal, scopes, Map.of())) + .registeredClient(CLIENT) + .build(); + } + + private static OAuth2AuthorizationCodeRequestAuthenticationContext authorizationRequest(Set scopes) { + OAuth2AuthorizationCodeRequestAuthenticationToken request = new OAuth2AuthorizationCodeRequestAuthenticationToken( + "https://as.example/oauth2/authorize", CLIENT.getClientId(), + new TestingAuthenticationToken("customer", null), "https://tp.example/cb", "state-1", scopes, Map.of()); + return OAuth2AuthorizationCodeRequestAuthenticationContext.with(request).registeredClient(CLIENT).build(); + } + + @Test + @DisplayName("client_credentials: a customer FB scope is invalid_scope") + void clientCredentialsRejectsCustomerScope() { + assertThatThrownBy(() -> EspiGrantScopePolicy.clientCredentialsValidator() + .accept(clientCredentials(Set.of(CUSTOMER_SCOPE)))) + .isInstanceOfSatisfying(OAuth2AuthenticationException.class, + e -> assertThat(e.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE)); + } + + @ParameterizedTest + @ValueSource(strings = {"ThirdParty_Admin_Access", EspiGrantScopePolicy.REGISTRATION_RENEWAL_SCOPE}) + @DisplayName("client_credentials: the client access token and renewal scopes are allowed") + void clientCredentialsAllowsAdministrativeScopes(String scope) { + assertThatCode(() -> EspiGrantScopePolicy.clientCredentialsValidator().accept(clientCredentials(Set.of(scope)))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("client_credentials: Spring's own check still runs (an unregistered scope is invalid_scope)") + void clientCredentialsKeepsDefaultCheck() { + assertThatThrownBy(() -> EspiGrantScopePolicy.clientCredentialsValidator() + .accept(clientCredentials(Set.of("DataCustodian_Admin_Access")))) + .isInstanceOfSatisfying(OAuth2AuthenticationException.class, + e -> assertThat(e.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE)); + } + + @ParameterizedTest + @ValueSource(strings = {"ThirdParty_Admin_Access", EspiGrantScopePolicy.REGISTRATION_RENEWAL_SCOPE}) + @DisplayName("authorization_code: an administrative scope is invalid_scope, redirected to the client") + void authorizationCodeRejectsAdministrativeScope(String scope) { + assertThatThrownBy(() -> EspiGrantScopePolicy.authorizationCodeValidator() + .accept(authorizationRequest(Set.of(CUSTOMER_SCOPE, scope)))) + .isInstanceOfSatisfying(OAuth2AuthorizationCodeRequestAuthenticationException.class, e -> assertThat(e) + .satisfies(ex -> assertThat(ex.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE)) + .satisfies(ex -> assertThat(ex.getAuthorizationCodeRequestAuthentication().getRedirectUri()) + .isEqualTo("https://tp.example/cb")) + .satisfies(ex -> assertThat(ex.getAuthorizationCodeRequestAuthentication().getState()) + .isEqualTo("state-1"))); + } + + @Test + @DisplayName("authorization_code: a customer FB scope is allowed") + void authorizationCodeAllowsCustomerScope() { + assertThatCode(() -> EspiGrantScopePolicy.authorizationCodeValidator() + .accept(authorizationRequest(Set.of(CUSTOMER_SCOPE)))) + .doesNotThrowAnyException(); + } + + /** Spellings the Data Custodian's tolerant parser still reads as customer Function Blocks. */ + @ParameterizedTest + @ValueSource(strings = {"IntervalDuration=3600;FB=4_5_15", " FB=4_5_15", "fb=4_5_15", "FB =4", "Foo;FB=1_3_4"}) + @DisplayName("client_credentials: any spelling of a customer scope is invalid_scope (allowlist, fails closed)") + void clientCredentialsRejectsAnySpellingOfCustomerScope(String scope) { + assertThat(EspiGrantScopePolicy.isClientCredentialsScope(scope)).isFalse(); + assertThat(EspiGrantScopePolicy.isAuthorizationCodeScope(scope)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"ThirdParty_Admin_Access", "DataCustodian_Admin_Access", "Upload_Admin_Access", + "FB=36_40", "fb=36_40", "FB=36", "FB=40;IntervalDuration=900", "FB=4_36", "authserver.admin", + "client.create", "FB=abc", "FB=", "FB_1", "anything_else"}) + @DisplayName("authorization_code: administrative, reserved-FB, unparseable and unknown scopes are not allowed") + void authorizationCodeAllowlist(String scope) { + assertThat(EspiGrantScopePolicy.isAuthorizationCodeScope(scope)).isFalse(); + } + + @Test + @DisplayName("scope classification: each grant's allowlist") + void scopeClassification() { + assertThat(Set.of(CUSTOMER_SCOPE, "FB=1_3_4_5_13_14_39", "openid", "profile")) + .allMatch(EspiGrantScopePolicy::isAuthorizationCodeScope) + .noneMatch(EspiGrantScopePolicy::isClientCredentialsScope); + assertThat(EspiGrantScopePolicy.ADMINISTRATIVE_SCOPES) + .allMatch(EspiGrantScopePolicy::isClientCredentialsScope) + .noneMatch(EspiGrantScopePolicy::isAuthorizationCodeScope); + assertThat(Set.of(" ThirdParty_Admin_Access", "thirdparty_admin_access", "FB=36_40;", "openid")) + .noneMatch(EspiGrantScopePolicy::isClientCredentialsScope); + } +} diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminEndpointAuthorizationIntegrationTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminEndpointAuthorizationIntegrationTest.java index 9568299c..98ba5a0d 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminEndpointAuthorizationIntegrationTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminEndpointAuthorizationIntegrationTest.java @@ -87,6 +87,8 @@ class AdminEndpointAuthorizationIntegrationTest extends AbstractAuthserverIntegr private static final String REDIRECT_URI = "https://tp.example/cb"; private static final String SCOPE = "FB_1"; + /** client_credentials yields only administrative scopes (#251, EspiGrantScopePolicy). */ + private static final String CLIENT_CREDENTIALS_SCOPE = "ThirdParty_Admin_Access"; @Autowired private MockMvc mockMvc; @@ -131,6 +133,7 @@ void seedVictimClient() { .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri(REDIRECT_URI) .scope(SCOPE) + .scope(CLIENT_CREDENTIALS_SCOPE) .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(true) .requireProofKey(false) @@ -357,7 +360,7 @@ void tokenAndIntrospectionStillWork() throws Exception { MvcResult token = mockMvc.perform(post("/oauth2/token") .with(httpBasic(victimClientId, victimClientSecret)) .param("grant_type", "client_credentials") - .param("scope", SCOPE)) + .param("scope", CLIENT_CREDENTIALS_SCOPE)) .andExpect(status().isOk()) .andExpect(jsonPath("$.access_token").isNotEmpty()) .andReturn(); diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java index 165cc80a..43b1807d 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java @@ -280,6 +280,67 @@ void shouldValidateRequestedScopes() throws Exception { } } + @Nested + @DisplayName("ESPI per-grant scope policy (#251)") + class PerGrantScopePolicyTests { + + private String dualClientId; + private String dualClientSecret; + + /** Registered for both grants and both kinds of scope: what Spring AS alone would let through. */ + @BeforeEach + void seedDualGrantClient() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + dualClientId = "sec-it-dual-" + suffix; + dualClientSecret = "sec-it-dual-secret-" + suffix; + registeredClientRepository.save(RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId(dualClientId) + .clientName("Security IT dual-grant ThirdParty") + .clientSecret("{noop}" + dualClientSecret) + .clientIdIssuedAt(Instant.now()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .redirectUri(REDIRECT_URI) + .scope(ESPI_SCOPE_HOURLY) + .scope("ThirdParty_Admin_Access") + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(true) + .requireProofKey(false) + .build()) + .tokenSettings(TokenSettings.builder() + .accessTokenFormat(OAuth2TokenFormat.REFERENCE) + .build()) + .build()); + } + + @Test + @DisplayName("client_credentials cannot obtain a customer FB scope, even when registered for it") + void clientCredentialsCannotGetCustomerScope() throws Exception { + mockMvc.perform(clientCredentials(dualClientId, dualClientSecret, ESPI_SCOPE_HOURLY)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_scope")); + + mockMvc.perform(clientCredentials(dualClientId, dualClientSecret, "ThirdParty_Admin_Access")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("authorization_code cannot obtain an administrative scope, even when registered for it") + void authorizationCodeCannotGetAdministrativeScope() throws Exception { + mockMvc.perform(authorize(dualClientId, REDIRECT_URI, "ThirdParty_Admin_Access")) + .andExpect(status().isFound()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .startsWith(REDIRECT_URI) + .contains("error=invalid_scope")); + + mockMvc.perform(authorize(dualClientId, REDIRECT_URI, ESPI_SCOPE_HOURLY)) + .andExpect(status().isFound()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .contains("/authorize/delegate")); + } + } + @Nested @DisplayName("CSRF Protection Tests") class CsrfProtectionTests {