Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)));
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
* <ul>
* <li><b>client_credentials</b> (client access token, registration-token renewal, infrastructure
* clients) may yield only the exact {@link #ADMINISTRATIVE_SCOPES} strings.</li>
* <li><b>authorization_code</b> (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}.</li>
* </ul>
* 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<String> 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<Integer> RESERVED_FUNCTION_BLOCKS = Set.of(36, 40);

/** Non-ESPI scopes a customer authorization request may carry. */
private static final Set<String> 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<Set<Integer>> functionBlocks(String scope) {
if (scope == null || scope.isBlank()) {
return Optional.empty();
}
Set<Integer> 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<OAuth2ClientCredentialsAuthenticationContext> clientCredentialsValidator() {
return OAuth2ClientCredentialsAuthenticationValidator.DEFAULT_SCOPE_VALIDATOR
.andThen(EspiGrantScopePolicy::requireClientCredentialsScopes);
}

/** Spring's default authorization request checks, followed by the ESPI rule. */
public static Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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);
}
}
Loading
Loading