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:
+ *
+ * - client_credentials (client access token, registration-token renewal, infrastructure
+ * clients) may yield only the exact {@link #ADMINISTRATIVE_SCOPES} strings.
+ * - authorization_code (customer consent) may yield only {@code openid} / {@code profile}
+ * and ESPI customer data scopes: scopes that, parsed the way the Data Custodian parses them
+ * ({@code EspiScope}: {@code ;}-separated, trimmed, case-insensitive {@code FB} key, {@code _}-separated
+ * integers), name at least one Function Block and none of the {@link #RESERVED_FUNCTION_BLOCKS}.
+ *
+ * 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 {