From 8ff7f3e8f4e575bb5bc7550762e27c9159f9b48e Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:37:39 -0500 Subject: [PATCH 1/3] fix(authserver): restrict each OAuth grant to its own kind of scope (#251) Spring Authorization Server only checks that a requested scope is registered for the client, whatever the grant. ESPI keeps customer data and administrative tokens apart, so: - client_credentials cannot be granted an ESPI customer data scope (any FB=... other than the FB=36_40 registration-token renewal scope); - authorization_code cannot be granted an administrative scope (ThirdParty_Admin_Access, DataCustodian_Admin_Access, FB=36_40, authserver.admin, client.create). The client receives invalid_scope at its redirect URI, as Spring does for its own scope check. EspiGrantScopePolicy runs after Spring's default validators on the token and authorization endpoints. Tested with unit tests and a Docker-backed SecurityIntegrationTest case using a client registered for both grants. Co-Authored-By: Claude Opus 5.5 --- .../config/AuthorizationServerConfig.java | 20 ++- .../grant/EspiGrantScopePolicy.java | 110 ++++++++++++++ .../grant/EspiGrantScopePolicyTest.java | 137 ++++++++++++++++++ .../integration/SecurityIntegrationTest.java | 61 ++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java create mode 100644 openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java 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..2830b4a0 --- /dev/null +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java @@ -0,0 +1,110 @@ +/* + * + * 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.Set; +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 separates the token kinds: + * + * 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"; + + /** Scopes only a client may hold for itself, never granted through a customer's consent. */ + public static final Set ADMINISTRATIVE_SCOPES = Set.of( + "ThirdParty_Admin_Access", + "DataCustodian_Admin_Access", + REGISTRATION_RENEWAL_SCOPE, + "authserver.admin", + "client.create"); + + private EspiGrantScopePolicy() { + } + + /** True for an ESPI customer data scope: a Function Block scope that is not the renewal scope. */ + public static boolean isCustomerDataScope(String scope) { + return scope != null && scope.startsWith("FB=") && !REGISTRATION_RENEWAL_SCOPE.equals(scope); + } + + /** Spring's default client_credentials scope check, followed by the ESPI rule. */ + public static Consumer clientCredentialsValidator() { + return OAuth2ClientCredentialsAuthenticationValidator.DEFAULT_SCOPE_VALIDATOR + .andThen(EspiGrantScopePolicy::rejectCustomerDataScopes); + } + + /** Spring's default authorization request checks, followed by the ESPI rule. */ + public static Consumer authorizationCodeValidator() { + return new OAuth2AuthorizationCodeRequestAuthenticationValidator() + .andThen(EspiGrantScopePolicy::rejectAdministrativeScopes); + } + + static void rejectCustomerDataScopes(OAuth2ClientCredentialsAuthenticationContext context) { + OAuth2ClientCredentialsAuthenticationToken request = context.getAuthentication(); + if (request.getScopes().stream().anyMatch(EspiGrantScopePolicy::isCustomerDataScope)) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, + "client_credentials cannot grant an ESPI customer data (Function Block) scope", null)); + } + } + + static void rejectAdministrativeScopes(OAuth2AuthorizationCodeRequestAuthenticationContext context) { + OAuth2AuthorizationCodeRequestAuthenticationToken request = context.getAuthentication(); + if (request.getScopes().stream().noneMatch(ADMINISTRATIVE_SCOPES::contains)) { + 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 (administrative scopes are not granted through customer authorization)", + 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..fa0f5fca --- /dev/null +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java @@ -0,0 +1,137 @@ +/* + * + * 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(); + } + + @Test + @DisplayName("only FB scopes other than FB=36_40 count as customer data scopes") + void customerDataScopeClassification() { + assertThat(Set.of(CUSTOMER_SCOPE, "FB=1_3_4_5_13_14_39")).allMatch(EspiGrantScopePolicy::isCustomerDataScope); + assertThat(Set.of("FB=36_40", "ThirdParty_Admin_Access", "openid")).noneMatch(EspiGrantScopePolicy::isCustomerDataScope); + } +} 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 { From 1a102a325e7896b95e5e52cbe111fa615f690713 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:42:46 -0500 Subject: [PATCH 2/3] fix(authserver): make the per-grant scope rules allowlists (#251) The Data Custodian parses scopes tolerantly (any segment order, trimmed, case-insensitive FB key), so a prefix check on "FB=" could be bypassed with a different spelling, and Upload_Admin_Access was missing from the administrative list. Both rules now fail closed: - client_credentials: only the exact administrative scope strings, including Upload_Admin_Access; - authorization_code: only openid/profile, or a scope that parses like EspiScope to Function Blocks other than the reserved 36 and 40. Unparseable or unknown scopes are rejected. Co-Authored-By: Claude Opus 5.5 --- .../grant/EspiGrantScopePolicy.java | 86 +++++++++++++++---- .../grant/EspiGrantScopePolicyTest.java | 32 ++++++- 2 files changed, 97 insertions(+), 21 deletions(-) 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 index 2830b4a0..da012dea 100644 --- 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 @@ -32,20 +32,25 @@ 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 separates the token kinds: + * 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 never carry a customer data scope: any {@code FB=...} scope other than the - * {@value #REGISTRATION_RENEWAL_SCOPE} renewal scope is {@code invalid_scope}.
  • - *
  • authorization_code (customer consent) may never carry an administrative scope - * ({@link #ADMINISTRATIVE_SCOPES}).
  • + * 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. */ @@ -54,45 +59,92 @@ 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"; - /** Scopes only a client may hold for itself, never granted through a customer's consent. */ + /** 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 for an ESPI customer data scope: a Function Block scope that is not the renewal scope. */ - public static boolean isCustomerDataScope(String scope) { - return scope != null && scope.startsWith("FB=") && !REGISTRATION_RENEWAL_SCOPE.equals(scope); + /** 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::rejectCustomerDataScopes); + .andThen(EspiGrantScopePolicy::requireClientCredentialsScopes); } /** Spring's default authorization request checks, followed by the ESPI rule. */ public static Consumer authorizationCodeValidator() { return new OAuth2AuthorizationCodeRequestAuthenticationValidator() - .andThen(EspiGrantScopePolicy::rejectAdministrativeScopes); + .andThen(EspiGrantScopePolicy::requireAuthorizationCodeScopes); } - static void rejectCustomerDataScopes(OAuth2ClientCredentialsAuthenticationContext context) { + static void requireClientCredentialsScopes(OAuth2ClientCredentialsAuthenticationContext context) { OAuth2ClientCredentialsAuthenticationToken request = context.getAuthentication(); - if (request.getScopes().stream().anyMatch(EspiGrantScopePolicy::isCustomerDataScope)) { + if (!request.getScopes().stream().allMatch(EspiGrantScopePolicy::isClientCredentialsScope)) { throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, - "client_credentials cannot grant an ESPI customer data (Function Block) scope", null)); + "client_credentials grants only administrative scopes, never ESPI customer data scopes", null)); } } - static void rejectAdministrativeScopes(OAuth2AuthorizationCodeRequestAuthenticationContext context) { + static void requireAuthorizationCodeScopes(OAuth2AuthorizationCodeRequestAuthenticationContext context) { OAuth2AuthorizationCodeRequestAuthenticationToken request = context.getAuthentication(); - if (request.getScopes().stream().noneMatch(ADMINISTRATIVE_SCOPES::contains)) { + 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. @@ -104,7 +156,7 @@ static void rejectAdministrativeScopes(OAuth2AuthorizationCodeRequestAuthenticat redirectUri, request.getState(), request.getScopes(), request.getAdditionalParameters()); result.setAuthenticated(true); throw new OAuth2AuthorizationCodeRequestAuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, - "OAuth 2.0 Parameter: scope (administrative scopes are not granted through customer authorization)", + "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 index fa0f5fca..7667501b 100644 --- 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 @@ -128,10 +128,34 @@ void authorizationCodeAllowsCustomerScope() { .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("only FB scopes other than FB=36_40 count as customer data scopes") - void customerDataScopeClassification() { - assertThat(Set.of(CUSTOMER_SCOPE, "FB=1_3_4_5_13_14_39")).allMatch(EspiGrantScopePolicy::isCustomerDataScope); - assertThat(Set.of("FB=36_40", "ThirdParty_Admin_Access", "openid")).noneMatch(EspiGrantScopePolicy::isCustomerDataScope); + @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); } } From 59b9d7e7821e14cc9010cf4e89708815e1d32e22 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:50:48 -0500 Subject: [PATCH 3/3] test(#251): request an administrative scope in the client_credentials introspection test The GHSA-mh65 test minted a client_credentials token for the placeholder scope FB_1, which the per-grant allowlist now rejects. It requests ThirdParty_Admin_Access instead. Co-Authored-By: Claude Opus 5.5 --- .../AdminEndpointAuthorizationIntegrationTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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();