Skip to content
Merged
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 @@ -36,7 +36,6 @@ public record BackchannelRequest(
@JsonProperty("client_id") String clientId,
@JsonProperty("granted_scope") String grantedScope,
@JsonProperty("retail_customer_id") Long retailCustomerId,
@JsonProperty("selected_usage_point_ids") List<UUID> selectedUsagePointIds,
@JsonProperty("customer_resource_uri") String customerResourceUri
@JsonProperty("selected_usage_point_ids") List<UUID> selectedUsagePointIds
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,11 @@
* narrowing); {@code ;}-delimited ESPI tokens
* @param selectedUsagePointIds usage points the customer chose to share with the TP; may be empty
* for grants that include only Customer/PII scope
* @param customerResourceUri absolute URI for the customer/PII resource; non-null iff the scope
* includes a Customer/PII Function Block (54&ndash;62)
*/
public record GrantContext(
String correlationId,
Long retailCustomerId,
String approvedScope,
List<UUID> selectedUsagePointIds,
String customerResourceUri
List<UUID> selectedUsagePointIds
) implements Serializable {
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ public class GrantContextEnrichingAuthorizationService implements OAuth2Authoriz
public static final String ATTR_RETAIL_CUSTOMER_ID = "espi.retail_customer_id";
public static final String ATTR_GRANTED_SCOPE = "espi.granted_scope";
public static final String ATTR_SELECTED_USAGE_POINT_IDS = "espi.selected_usage_point_ids";
public static final String ATTR_CUSTOMER_RESOURCE_URI = "espi.customer_resource_uri";
public static final String ATTR_CORRELATION_ID = "espi.correlation_id";

private final OAuth2AuthorizationService delegate;
Expand Down Expand Up @@ -131,9 +130,6 @@ private OAuth2Authorization maybeEnrich(OAuth2Authorization authorization) {
.attribute(ATTR_GRANTED_SCOPE, ctx.approvedScope())
.attribute(ATTR_SELECTED_USAGE_POINT_IDS, serializeUuids(ctx.selectedUsagePointIds()))
.attribute(ATTR_CORRELATION_ID, ctx.correlationId());
if (ctx.customerResourceUri() != null && !ctx.customerResourceUri().isBlank()) {
b.attribute(ATTR_CUSTOMER_RESOURCE_URI, ctx.customerResourceUri());
}
return b.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,6 @@ public void customize(OAuth2TokenClaimsContext context) {
GrantContextEnrichingAuthorizationService.ATTR_CORRELATION_ID);
String selectedUpsSerialized = authorization.getAttribute(
GrantContextEnrichingAuthorizationService.ATTR_SELECTED_USAGE_POINT_IDS);
String customerResourceUri = authorization.getAttribute(
GrantContextEnrichingAuthorizationService.ATTR_CUSTOMER_RESOURCE_URI);

String clientId = context.getRegisteredClient().getClientId();
List<UUID> selectedUps = GrantContextEnrichingAuthorizationService
Expand All @@ -118,8 +116,7 @@ public void customize(OAuth2TokenClaimsContext context) {
clientId,
grantedScope,
Long.valueOf(retailCustomerIdStr),
selectedUps,
customerResourceUri);
selectedUps);

BackchannelResponse response;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,7 @@ private String handleAllow(SignedHandoff.Return payload,
payload.correlationId(),
retailCustomerId,
payload.approvedScope(),
payload.selectedUsagePointIds(),
payload.customerResourceUri()));
payload.selectedUsagePointIds()));

// Redirect back to /oauth2/authorize with the original TP query params. Spring AS finds
// the saved authorization request (still in session), sees authenticated user + matching
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ void happyPath() {
""".formatted(AUTH_ID, RES_SUB_ID, CUST_SUB_ID, RES_SUB_ID, AUTH_ID, CUST_SUB_ID)));

BackchannelResponse response = client.provision(new BackchannelRequest(
"corr-1", "tp-1", "FB_1;FB_4_5", 42L, List.of(UP_1), null));
"corr-1", "tp-1", "FB_1;FB_4_5", 42L, List.of(UP_1)));

assertThat(response)
.extracting(BackchannelResponse::authorizationId,
Expand Down Expand Up @@ -110,7 +110,7 @@ void clientError() {
"""));

assertThatThrownBy(() -> client.provision(
new BackchannelRequest("c", "tp", "", 1L, List.of(), null)))
new BackchannelRequest("c", "tp", "", 1L, List.of())))
.isInstanceOf(DataCustodianBackchannelException.class)
.hasMessageContaining("400")
.hasMessageContaining("granted_scope is missing");
Expand All @@ -125,7 +125,7 @@ void serverError() {
.body("{\"error\":\"internal\"}"));

assertThatThrownBy(() -> client.provision(
new BackchannelRequest("c", "tp", "FB_1", 1L, List.of(), null)))
new BackchannelRequest("c", "tp", "FB_1", 1L, List.of())))
.isInstanceOf(DataCustodianBackchannelException.class)
.hasMessageContaining("500");
}
Expand All @@ -137,7 +137,7 @@ void unexpectedStatus() {
.andRespond(withRawStatus(418).body("no coffee"));

assertThatThrownBy(() -> client.provision(
new BackchannelRequest("c", "tp", "FB_1", 1L, List.of(), null)))
new BackchannelRequest("c", "tp", "FB_1", 1L, List.of())))
.isInstanceOf(DataCustodianBackchannelException.class)
.hasMessageContaining("418");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ void enrichesFromSessionOnFirstSave() {
MockHttpSession session = new MockHttpSession();
request.setSession(session);
GrantContext ctx = new GrantContext(
"corr-1", 42L, "FB_1;FB_4_5", List.of(UP_1, UP_2),
"https://dc.example/RetailCustomer/42");
"corr-1", 42L, "FB_1;FB_4_5", List.of(UP_1, UP_2));
sessionStore.put(session, ctx);

service.save(blankAuthorization("auth-1"));
Expand All @@ -93,9 +92,6 @@ void enrichesFromSessionOnFirstSave() {
assertThat(saved.<String>getAttribute(
GrantContextEnrichingAuthorizationService.ATTR_CORRELATION_ID))
.isEqualTo("corr-1");
assertThat(saved.<String>getAttribute(
GrantContextEnrichingAuthorizationService.ATTR_CUSTOMER_RESOURCE_URI))
.isEqualTo("https://dc.example/RetailCustomer/42");

// Selected UPs round-trip via parseUuids
String serialized = saved.getAttribute(
Expand All @@ -109,7 +105,7 @@ void enrichesFromSessionOnFirstSave() {
void sessionConsumedSingleUse() {
MockHttpSession session = new MockHttpSession();
request.setSession(session);
GrantContext ctx = new GrantContext("corr-2", 1L, "FB_1", List.of(), null);
GrantContext ctx = new GrantContext("corr-2", 1L, "FB_1", List.of());
sessionStore.put(session, ctx);

service.save(blankAuthorization("auth-2a"));
Expand All @@ -132,7 +128,7 @@ void alreadyEnrichedNotDoubled() {
MockHttpSession session = new MockHttpSession();
request.setSession(session);
// Session has fresh context...
sessionStore.put(session, new GrantContext("corr-3", 1L, "FB_1", List.of(), null));
sessionStore.put(session, new GrantContext("corr-3", 1L, "FB_1", List.of()));

// ...but the incoming authorization already has the marker attribute, so the wrapper
// must leave it alone.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ void setUp() {
void putThenConsumeReturnsContextOnce() {
GrantContext ctx = new GrantContext(
"corr-1", 42L, "FB_1;FB_4_5",
List.of(UUID.fromString("00000000-0000-5000-8000-000000000001")),
"https://dc.example/cust/42");
List.of(UUID.fromString("00000000-0000-5000-8000-000000000001")));
store.put(session, ctx);

assertThat(store.consume(session)).isEqualTo(ctx);
Expand All @@ -55,7 +54,7 @@ void consumeFreshSessionReturnsNull() {
@Test
@DisplayName("peek does not remove the entry")
void peekDoesNotRemove() {
GrantContext ctx = new GrantContext("corr-2", 99L, "FB_1", List.of(), null);
GrantContext ctx = new GrantContext("corr-2", 99L, "FB_1", List.of());
store.put(session, ctx);

assertThat(store.peek(session)).isEqualTo(ctx);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ class AuthCodeFlowOrchestrationIntegrationTest extends AbstractAuthserverIntegra
private static final String REDIRECT_URI = "https://tp.example/cb";
private static final String SCOPE = "FB_1";
private static final String CUSTOMER_ID = "42";
private static final String CUSTOMER_RESOURCE_URI = "https://dc.example/RetailCustomer/42";

@Autowired
private MockMvc mockMvc;
Expand Down Expand Up @@ -148,7 +147,6 @@ void fullAuthCodeFlowAugmentsTokenResponse() throws Exception {
UUID.randomUUID().toString().replace("-", ""), // single-use nonce
CUSTOMER_ID,
List.of(selectedUsagePoint),
CUSTOMER_RESOURCE_URI,
SignedHandoff.Return.CONSENT_ALLOW,
SCOPE));

Expand All @@ -175,7 +173,7 @@ void fullAuthCodeFlowAugmentsTokenResponse() throws Exception {
assertThat(code).isNotBlank();

// 3) Exchange the code at /oauth2/token -> back-channel fires, response augmented with URIs.
mockMvc.perform(post("/oauth2/token")
MvcResult tokenResult = mockMvc.perform(post("/oauth2/token")
.with(httpBasic(clientId, clientSecret))
.param("grant_type", "authorization_code")
.param("code", code)
Expand All @@ -190,7 +188,10 @@ void fullAuthCodeFlowAugmentsTokenResponse() throws Exception {
// non-standard and removed in #160.
.andExpect(jsonPath("$.authorization_id").doesNotExist())
.andExpect(jsonPath("$.resource_subscription_id").doesNotExist())
.andExpect(jsonPath("$.customer_subscription_id").doesNotExist());
.andExpect(jsonPath("$.customer_subscription_id").doesNotExist())
.andReturn();
String accessToken = new com.fasterxml.jackson.databind.ObjectMapper()
.readTree(tokenResult.getResponse().getContentAsString()).get("access_token").asText();

// The back-channel was called with the customer-selection context carried through the flow.
ArgumentCaptor<BackchannelRequest> captor = ArgumentCaptor.forClass(BackchannelRequest.class);
Expand All @@ -201,7 +202,22 @@ void fullAuthCodeFlowAugmentsTokenResponse() throws Exception {
assertThat(sent.grantedScope()).isEqualTo(SCOPE);
assertThat(sent.retailCustomerId()).isEqualTo(42L);
assertThat(sent.selectedUsagePointIds()).containsExactly(selectedUsagePoint);
assertThat(sent.customerResourceUri()).isEqualTo(CUSTOMER_RESOURCE_URI);

// 4) Introspection (#160 follow-up): /oauth2/introspect must surface the SAME ESPI URI claims
// + active + FB-grammar scope, and must NOT carry the removed *_id fields. (Verifies the C4
// claims actually reach introspection, not just the /oauth2/token response.)
mockMvc.perform(post("/oauth2/introspect")
.with(httpBasic(clientId, clientSecret))
.param("token", accessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.active").value(true))
.andExpect(jsonPath("$.scope").value(SCOPE))
.andExpect(jsonPath("$.resourceURI").value(resourceUri))
.andExpect(jsonPath("$.authorizationURI").value(authorizationUri))
.andExpect(jsonPath("$.customerResourceURI").value(customerResourceUri))
.andExpect(jsonPath("$.authorization_id").doesNotExist())
.andExpect(jsonPath("$.resource_subscription_id").doesNotExist())
.andExpect(jsonPath("$.customer_subscription_id").doesNotExist());
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ void setUp() {
void withGrantContextWritesClaims() {
OAuth2Authorization auth = authorizationWithGrantContext(
"corr-9", 42L, "FB_1;FB_4_5",
UP_1 + ";" + UP_2,
"https://dc.example/RetailCustomer/42");
UP_1 + ";" + UP_2);

when(client.provision(any())).thenReturn(new BackchannelResponse(
AUTH_ID, RES_SUB_ID, CUST_SUB_ID,
Expand All @@ -93,10 +92,8 @@ void withGrantContextWritesClaims() {
.extracting(BackchannelRequest::correlationId,
BackchannelRequest::clientId,
BackchannelRequest::grantedScope,
BackchannelRequest::retailCustomerId,
BackchannelRequest::customerResourceUri)
.containsExactly("corr-9", "tp-1", "FB_1;FB_4_5", 42L,
"https://dc.example/RetailCustomer/42");
BackchannelRequest::retailCustomerId)
.containsExactly("corr-9", "tp-1", "FB_1;FB_4_5", 42L);
assertThat(sent.selectedUsagePointIds()).containsExactly(UP_1, UP_2);

Map<String, Object> claims = ctx.getClaims().build().getClaims();
Expand Down Expand Up @@ -127,7 +124,7 @@ void noGrantContextSkipsBackchannel() {
@DisplayName("back-channel failure surfaces as OAuth2AuthenticationException(server_error)")
void backchannelFailureSurfacesAsOAuth2Error() {
OAuth2Authorization auth = authorizationWithGrantContext(
"corr-fail", 7L, "FB_1", "", null);
"corr-fail", 7L, "FB_1", "");
when(client.provision(any())).thenThrow(new DataCustodianBackchannelException("DC 503"));

OAuth2TokenClaimsContext ctx = buildContext(auth);
Expand All @@ -144,7 +141,7 @@ void backchannelFailureSurfacesAsOAuth2Error() {
@DisplayName("response with only resource URI (PII-less grant): customer claim omitted")
void piiLessGrantOmitsCustomerClaim() {
OAuth2Authorization auth = authorizationWithGrantContext(
"corr-pii-less", 8L, "FB_1", UP_1.toString(), null);
"corr-pii-less", 8L, "FB_1", UP_1.toString());
when(client.provision(any())).thenReturn(new BackchannelResponse(
AUTH_ID, RES_SUB_ID, null,
"https://dc.example/Subscription/" + RES_SUB_ID,
Expand All @@ -165,7 +162,7 @@ void piiLessGrantOmitsCustomerClaim() {
@DisplayName("refresh-token customization: skipped")
void refreshTokenSkipped() {
OAuth2Authorization auth = authorizationWithGrantContext(
"corr-ref", 1L, "FB_1", "", null);
"corr-ref", 1L, "FB_1", "");

OAuth2TokenClaimsContext ctx = OAuth2TokenClaimsContext
.with(OAuth2TokenClaimsSet.builder())
Expand Down Expand Up @@ -199,8 +196,7 @@ private static OAuth2Authorization blankAuthorization() {
}

private static OAuth2Authorization authorizationWithGrantContext(String cid, long retailCustomerId,
String scope, String selectedUps,
String customerResourceUri) {
String scope, String selectedUps) {
OAuth2Authorization.Builder b = OAuth2Authorization.withRegisteredClient(registeredClient())
.id("auth-" + cid)
.principalName(String.valueOf(retailCustomerId))
Expand All @@ -211,10 +207,6 @@ private static OAuth2Authorization authorizationWithGrantContext(String cid, lon
.attribute(GrantContextEnrichingAuthorizationService.ATTR_GRANTED_SCOPE, scope)
.attribute(GrantContextEnrichingAuthorizationService.ATTR_SELECTED_USAGE_POINT_IDS,
selectedUps == null ? "" : selectedUps);
if (customerResourceUri != null) {
b.attribute(GrantContextEnrichingAuthorizationService.ATTR_CUSTOMER_RESOURCE_URI,
customerResourceUri);
}
return b.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ void expiredTokenIs400() throws Exception {
Instant past = Instant.now().minusSeconds(3600);
SignedHandoff.Return expired = SignedHandoff.Return.of(
"corr-expired", past.minusSeconds(600), past, "nonce-x",
"42", List.of(), "https://dc.example/cust/1",
"42", List.of(),
SignedHandoff.Return.CONSENT_ALLOW, "FB_1");
String token = codec.encode(expired);

Expand Down Expand Up @@ -293,7 +293,6 @@ private static SignedHandoff.Return allowReturn(String cid, String approvedScope
UUID.randomUUID().toString().replace("-", ""),
"42",
List.of(),
"https://dc.example/RetailCustomer/1",
SignedHandoff.Return.CONSENT_ALLOW,
approvedScope);
}
Expand All @@ -305,7 +304,6 @@ private static SignedHandoff.Return denyReturn(String cid) {
UUID.randomUUID().toString().replace("-", ""),
"42",
List.of(),
"https://dc.example/RetailCustomer/1",
SignedHandoff.Return.CONSENT_DENY,
null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
* <ul>
* <li>Always creates exactly one <em>energy</em> Subscription (the {@code resource_uri} target).</li>
* <li>Creates a <em>customer/PII</em> Subscription only when the granted scope includes a
* Customer/PII Function Block (FB 54&ndash;62). The {@code customer_resource_uri} from the
* command is stored verbatim on the Authorization aggregate.</li>
* Customer/PII Function Block (FB 54&ndash;62). DC builds the canonical
* {@code customerResourceURI} itself and stores it on the Authorization aggregate.</li>
* <li>Persistence happens through the Authorization aggregate; Subscriptions are never saved
* independently.</li>
* </ul>
Expand Down Expand Up @@ -70,16 +70,13 @@ public interface SubscriptionProvisioningService {
* ids; tracked separately from the UUID5 migration)
* @param selectedUsagePointIds usage points the customer chose to share with this TP; may be
* empty for grants that include only customer/PII scope
* @param customerResourceUri absolute URI for the customer/PII resource the TP may GET;
* must be present iff the scope includes a Customer/PII FB
*/
record SubscriptionProvisionCommand(
String correlationId,
String clientId,
String grantedScope,
Long retailCustomerId,
List<UUID> selectedUsagePointIds,
String customerResourceUri
List<UUID> selectedUsagePointIds
) {}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,6 @@ public SubscriptionProvisionResult provisionFromGrant(SubscriptionProvisionComma
throw new IllegalArgumentException(
"Grant must include at least one selected usage point OR a Customer/PII FB scope");
}
if (includesPii && (command.customerResourceUri() == null || command.customerResourceUri().isBlank())) {
throw new IllegalArgumentException(
"customer_resource_uri is required when scope includes a Customer/PII FB (54-62)");
}
if (!includesPii && command.customerResourceUri() != null && !command.customerResourceUri().isBlank()) {
throw new IllegalArgumentException(
"customer_resource_uri must be absent when scope does not include a Customer/PII FB");
}

AuthorizationEntity authorization = newAuthorization(command, application, customer, includesPii);

Expand Down Expand Up @@ -173,9 +165,7 @@ private AuthorizationEntity newAuthorization(SubscriptionProvisionCommand comman
authorization.setStatus(AuthorizationEntity.STATUS_ACTIVE);
if (includesPii) {
// Build the canonical ESPI Batch/RetailCustomer URI from the retail-customer id DC already
// holds, rather than trusting the round-tripped handoff value (#160). The handoff's
// customer_resource_uri is now vestigial — removing it from the back-channel request/handoff
// is a follow-up structural cleanup.
// holds (#160). DC owns this value end-to-end; it is never round-tripped through the AS.
authorization.setCustomerResourceURI(
EspiBatchUri.batchRetailCustomer(resourceBaseUri, command.retailCustomerId()));
}
Expand Down
Loading
Loading