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
42 changes: 42 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Cross-service wire contracts (AS ↔ DC)

Canonical JSON examples of the HTTP wire formats exchanged between the Authorization
Server and the Data Custodian. These files are the **single source of truth** both
services bind to in their own tests — a lightweight, framework-free consumer-driven
contract suited to standalone Spring Boot services (deployed independently on EC2),
**not** a Spring Cloud microservice mesh.

## How the contract is enforced (no Spring Cloud, no Pact broker)

For each contract, two tests bind to the same file here:

- **Producer test** asserts the service's *actual* output matches this file.
- **Consumer test** asserts the service correctly *parses* this file.

If either side's wire format drifts from the file, that side's test fails. The fixture
is reviewed in PRs, so a deliberate wire change is a visible diff to this directory plus
a coordinated update on both sides.

What these contracts cover: the **wire format** (field names, types, the ESPI scope
grammar). What they deliberately do NOT cover: TLS, ingress, CORS, timeouts — those are
EC2 deployment concerns, not wire-format concerns.

## Contracts

The token-response / introspection wire format is defined by the **ESPI 4.0 standard** itself
(NAESB REQ.21 — its own example shows `"resourceURI":".../resource/Batch/Subscription/{id}"`,
`"scope":"FB=...;..."`, no `*_id` fields, `customerResourceURI` may be `""`). These fixtures
reproduce that standard shape for the two grant types:

| File | Grant type | `resourceURI` / `customerResourceURI` form |
|------|------------|--------------------------------------------|
| `token-response-subscription.json` | Authorization Code (Subscription) | `Batch/Subscription/{id}` / `Batch/RetailCustomer/{rc}` |
| `token-response-bulk.json` | Client Credentials (Bulk) | `Batch/Bulk/{bulkId}` / `Batch/Bulk/{bulkRcId}` |

- **Producer** (AS token endpoint / introspection, fed by DC `SubscriptionProvisioningServiceImpl`
via `EspiBatchUri`) must emit these URI forms and only the three `*URI` fields.
- **Consumer** (DC `ResourceValidationFilter` / `EspiScopeOpaqueTokenIntrospector`) parses ids back
out of the URIs via the same `EspiBatchUri`, and the `FB=...` scope via `EspiScope`.

UUIDs/ids/tokens in the fixtures are illustrative; the contract is the **shape** — field names,
the `Batch/{Subscription|Bulk|RetailCustomer}` URI forms, and the ESPI `FB=...` scope grammar.
9 changes: 9 additions & 0 deletions contracts/token-response-bulk.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"access_token": "OPAQUE_ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "FB=1_3_4_5_10_11_35;BlockDuration=daily;BR=1",
"resourceURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/Bulk/BULK_1",
"authorizationURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Authorization/CLIENTCREDS",
"customerResourceURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/Bulk/BULK_RC_1"
}
10 changes: 10 additions & 0 deletions contracts/token-response-subscription.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"access_token": "OPAQUE_ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "OPAQUE_REFRESH_TOKEN",
"scope": "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13",
"resourceURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/Subscription/503888",
"authorizationURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Authorization/503888",
"customerResourceURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/RetailCustomer/503888"
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,12 @@ public class EspiTokenResponseSuccessHandler implements AuthenticationSuccessHan
private final OAuth2AuthorizationService authorizationService;
private final OAuth2AccessTokenResponseHttpWriter responseWriter = new OAuth2AccessTokenResponseHttpWriter();

// ESPI 4.0 (REQ.21) surfaces only the three canonical URIs in the token response; the bare *_id
// claims were non-standard and were removed in #160 (consumers parse ids from the URIs).
private static final Set<String> ESPI_URI_CLAIMS = Set.of(
GrantBackchannelTokenCustomizer.CLAIM_RESOURCE_URI,
GrantBackchannelTokenCustomizer.CLAIM_AUTHORIZATION_URI,
GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI,
GrantBackchannelTokenCustomizer.CLAIM_AUTHORIZATION_ID,
GrantBackchannelTokenCustomizer.CLAIM_RESOURCE_SUBSCRIPTION_ID,
GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_SUBSCRIPTION_ID
GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI
);

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@
* </ol>
*
* <h2>Claim names</h2>
* Match the ESPI 1.1/4.0 token-response wire format the third party expects:
* {@code resourceURI}, {@code authorizationURI}, {@code customerResourceURI}. The auxiliary ids
* ({@code authorization_id}, {@code resource_subscription_id}, {@code customer_subscription_id})
* are surfaced as snake-case claims for AS-side audit and operator tooling.
* Exactly the ESPI 4.0 (REQ.21) token-response wire format the third party expects:
* {@code resourceURI}, {@code authorizationURI}, {@code customerResourceURI}. Consumers parse any
* needed ids out of those canonical URIs (see {@code EspiBatchUri}); the previously-emitted bare
* {@code *_id} claims were non-standard and were removed in #160.
*
* <h2>Failure handling</h2>
* Any {@link DataCustodianBackchannelException} is rethrown as an {@link OAuth2AuthenticationException}
Expand All @@ -79,9 +79,6 @@ public class GrantBackchannelTokenCustomizer implements OAuth2TokenCustomizer<OA
public static final String CLAIM_RESOURCE_URI = "resourceURI";
public static final String CLAIM_AUTHORIZATION_URI = "authorizationURI";
public static final String CLAIM_CUSTOMER_RESOURCE_URI = "customerResourceURI";
public static final String CLAIM_AUTHORIZATION_ID = "authorization_id";
public static final String CLAIM_RESOURCE_SUBSCRIPTION_ID = "resource_subscription_id";
public static final String CLAIM_CUSTOMER_SUBSCRIPTION_ID = "customer_subscription_id";

private final DataCustodianBackchannelClient backchannelClient;

Expand Down Expand Up @@ -139,6 +136,10 @@ public void customize(OAuth2TokenClaimsContext context) {
}

private static void writeClaims(OAuth2TokenClaimsContext context, BackchannelResponse response) {
// ESPI 4.0 (REQ.21) token-response / introspection augmentation carries ONLY the three canonical
// URIs — resourceURI, authorizationURI, customerResourceURI. The bare *_id fields were
// non-standard duplicates of the ids already embedded in those URIs (and customer_subscription_id
// was mislabeled); consumers parse ids from the URIs via EspiBatchUri instead. Removed per #160.
var claims = context.getClaims();
if (response.resourceUri() != null) {
claims.claim(CLAIM_RESOURCE_URI, response.resourceUri());
Expand All @@ -149,14 +150,5 @@ private static void writeClaims(OAuth2TokenClaimsContext context, BackchannelRes
if (response.customerResourceUri() != null) {
claims.claim(CLAIM_CUSTOMER_RESOURCE_URI, response.customerResourceUri());
}
if (response.authorizationId() != null) {
claims.claim(CLAIM_AUTHORIZATION_ID, response.authorizationId().toString());
}
if (response.resourceSubscriptionId() != null) {
claims.claim(CLAIM_RESOURCE_SUBSCRIPTION_ID, response.resourceSubscriptionId().toString());
}
if (response.customerSubscriptionId() != null) {
claims.claim(CLAIM_CUSTOMER_SUBSCRIPTION_ID, response.customerSubscriptionId().toString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,11 @@ void fullAuthCodeFlowAugmentsTokenResponse() throws Exception {
.andExpect(jsonPath("$.resourceURI").value(resourceUri))
.andExpect(jsonPath("$.authorizationURI").value(authorizationUri))
.andExpect(jsonPath("$.customerResourceURI").value(customerResourceUri))
.andExpect(jsonPath("$.authorization_id").value(authorizationId.toString()))
.andExpect(jsonPath("$.resource_subscription_id").value(resourceSubscriptionId.toString()))
.andExpect(jsonPath("$.customer_subscription_id").value(customerSubscriptionId.toString()));
// ESPI 4.0 token response carries only the three canonical URIs; the *_id claims were
// non-standard and removed in #160.
.andExpect(jsonPath("$.authorization_id").doesNotExist())
.andExpect(jsonPath("$.resource_subscription_id").doesNotExist())
.andExpect(jsonPath("$.customer_subscription_id").doesNotExist());

// The back-channel was called with the customer-selection context carried through the flow.
ArgumentCaptor<BackchannelRequest> captor = ArgumentCaptor.forClass(BackchannelRequest.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,8 @@ void withGrantContextWritesClaims() {
.containsEntry(GrantBackchannelTokenCustomizer.CLAIM_AUTHORIZATION_URI,
"https://dc.example/Authorization/" + AUTH_ID)
.containsEntry(GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI,
"https://dc.example/RetailCustomer/42/Customer/" + CUST_SUB_ID)
.containsEntry(GrantBackchannelTokenCustomizer.CLAIM_AUTHORIZATION_ID,
AUTH_ID.toString());
"https://dc.example/RetailCustomer/42/Customer/" + CUST_SUB_ID);
// *_id claims removed in #160 — consumers parse ids from the canonical URIs (EspiBatchUri).
}

@Test
Expand Down Expand Up @@ -159,8 +158,7 @@ void piiLessGrantOmitsCustomerClaim() {
assertThat(claims)
.containsEntry(GrantBackchannelTokenCustomizer.CLAIM_RESOURCE_URI,
"https://dc.example/Subscription/" + RES_SUB_ID)
.doesNotContainKey(GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI)
.doesNotContainKey(GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_SUBSCRIPTION_ID);
.doesNotContainKey(GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import org.greenbuttonalliance.espi.common.repositories.usage.RetailCustomerRepository;
import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository;
import org.greenbuttonalliance.espi.common.scope.EspiScope;
import org.greenbuttonalliance.espi.common.uri.EspiBatchUri;
import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService;
import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService;
import org.springframework.beans.factory.annotation.Value;
Expand Down Expand Up @@ -171,7 +172,12 @@ private AuthorizationEntity newAuthorization(SubscriptionProvisionCommand comman
authorization.setThirdParty(command.clientId());
authorization.setStatus(AuthorizationEntity.STATUS_ACTIVE);
if (includesPii) {
authorization.setCustomerResourceURI(command.customerResourceUri());
// 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.
authorization.setCustomerResourceURI(
EspiBatchUri.batchRetailCustomer(resourceBaseUri, command.retailCustomerId()));
}
return authorization;
}
Expand All @@ -191,11 +197,14 @@ private SubscriptionEntity newSubscription(SubscriptionProvisionCommand command,
return subscription;
}

// Canonical ESPI 4.0 Batch resource URIs via the single builder/parser shared with the consumers
// (DC ResourceValidationFilter) — see EspiBatchUri / #160. The previous "/Subscription/{id}" form
// omitted the required "/Batch/" segment and so failed DC's own resource validation.
private String subscriptionUri(UUID subscriptionId) {
return resourceBaseUri + "/Subscription/" + subscriptionId;
return EspiBatchUri.batchSubscription(resourceBaseUri, subscriptionId);
}

private String authorizationUri(UUID authorizationId) {
return resourceBaseUri + "/Authorization/" + authorizationId;
return EspiBatchUri.authorization(resourceBaseUri, authorizationId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
*
* Copyright (c) 2025 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.common.uri;

import java.util.Optional;

/**
* The single canonical builder and parser for the ESPI 4.0 <em>Batch</em> resource URIs carried in the
* token-response / introspection {@code resourceURI}, {@code authorizationURI}, and
* {@code customerResourceURI} fields (NAESB ESPI 4.0, REQ.21.6.2.1 API Interface).
*
* <p>The canonical forms (relative to a resource base such as
* {@code https://{host}/DataCustodian/espi/1_1/resource}) are:</p>
* <ul>
* <li>{@code .../Batch/Subscription/{subscriptionId}} — Subscription (authorization-code) flow</li>
* <li>{@code .../Batch/Bulk/{bulkId}} — Bulk (client-credentials) flow; the {@code bulkId} comes from
* the scope's {@code BR=} parameter</li>
* <li>{@code .../Batch/RetailCustomer/{retailCustomerId}} — customer/PII batch</li>
* <li>{@code .../Authorization/{authorizationId}}</li>
* </ul>
*
* <p><strong>Why one class builds and parses:</strong> the producer (DC subscription provisioning) and
* the consumers (DC {@code ResourceValidationFilter}, the resource-server introspector) previously
* encoded these URL formats independently and drifted — the producer emitted {@code /Subscription/{id}}
* while the consumer expected {@code /Batch/Subscription/{id}} (issue #160). Routing both sides through
* this single class makes that drift structurally impossible.</p>
*/
public final class EspiBatchUri {

private EspiBatchUri() {
}

// ------------------------------------------------------------------ builders

/** {@code {resourceBase}/Batch/Subscription/{subscriptionId}}. */
public static String batchSubscription(String resourceBase, Object subscriptionId) {
return base(resourceBase) + "/Batch/Subscription/" + require(subscriptionId, "subscriptionId");
}

/** {@code {resourceBase}/Batch/Bulk/{bulkId}}. */
public static String batchBulk(String resourceBase, Object bulkId) {
return base(resourceBase) + "/Batch/Bulk/" + require(bulkId, "bulkId");
}

/** {@code {resourceBase}/Batch/RetailCustomer/{retailCustomerId}}. */
public static String batchRetailCustomer(String resourceBase, Object retailCustomerId) {
return base(resourceBase) + "/Batch/RetailCustomer/" + require(retailCustomerId, "retailCustomerId");
}

/** {@code {resourceBase}/Authorization/{authorizationId}}. */
public static String authorization(String resourceBase, Object authorizationId) {
return base(resourceBase) + "/Authorization/" + require(authorizationId, "authorizationId");
}

// ------------------------------------------------------------------ parsers

/** Extract {@code subscriptionId} from a {@code .../Batch/Subscription/{id}...} URI. */
public static Optional<String> subscriptionId(String uri) {
return idAfter(uri, "/Batch/Subscription/");
}

/** Extract {@code bulkId} from a {@code .../Batch/Bulk/{id}...} URI. */
public static Optional<String> bulkId(String uri) {
return idAfter(uri, "/Batch/Bulk/");
}

/** Extract {@code retailCustomerId} from a {@code .../Batch/RetailCustomer/{id}...} URI. */
public static Optional<String> retailCustomerId(String uri) {
return idAfter(uri, "/Batch/RetailCustomer/");
}

/** Extract {@code authorizationId} from a {@code .../Authorization/{id}...} URI. */
public static Optional<String> authorizationId(String uri) {
return idAfter(uri, "/Authorization/");
}

/**
* Return the path segment immediately following {@code marker}, tolerant of deeper path segments and
* query/fragment suffixes — e.g. {@code .../Batch/Subscription/42/UsagePoint/7?x=1} yields {@code 42}.
* Returns empty when the marker is absent or the id segment is empty.
*/
static Optional<String> idAfter(String uri, String marker) {
if (uri == null) {
return Optional.empty();
}
int at = uri.indexOf(marker);
if (at < 0) {
return Optional.empty();
}
int start = at + marker.length();
int end = start;
while (end < uri.length()) {
char c = uri.charAt(end);
if (c == '/' || c == '?' || c == '#') {
break;
}
end++;
}
String id = uri.substring(start, end);
return id.isEmpty() ? Optional.empty() : Optional.of(id);
}

private static String base(String resourceBase) {
if (resourceBase == null || resourceBase.isBlank()) {
throw new IllegalArgumentException("resourceBase is required");
}
String trimmed = resourceBase.strip();
return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
}

private static String require(Object id, String name) {
if (id == null || id.toString().isBlank()) {
throw new IllegalArgumentException(name + " is required");
}
return id.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ void energyOnlyGrant_createsResourceSubscriptionAndNoPiiSubscription() {

assertThat(result.resourceSubscriptionId()).isNotNull();
assertThat(result.customerSubscriptionId()).isNull();
assertThat(result.resourceUri()).startsWith(BASE_URI + "/Subscription/");
assertThat(result.resourceUri()).startsWith(BASE_URI + "/Batch/Subscription/"); // ESPI standard Batch form (#160)
assertThat(result.authorizationUri()).startsWith(BASE_URI + "/Authorization/");
assertThat(result.customerResourceUri()).isNull();

Expand Down Expand Up @@ -134,10 +134,10 @@ void grantWithPiiScope_createsBothSubscriptions() {

assertThat(result.resourceSubscriptionId()).isNotNull();
assertThat(result.customerSubscriptionId()).isNotNull();
assertThat(result.customerResourceUri()).isEqualTo(PII_CUSTOMER_URI);
assertThat(result.customerResourceUri()).isEqualTo(BASE_URI + "/Batch/RetailCustomer/" + CUSTOMER_ID);

AuthorizationEntity saved = captureSavedAuthorization();
assertThat(saved.getCustomerResourceURI()).isEqualTo(PII_CUSTOMER_URI);
assertThat(saved.getCustomerResourceURI()).isEqualTo(BASE_URI + "/Batch/RetailCustomer/" + CUSTOMER_ID);
assertThat(saved.getSubscriptions()).hasSize(2);
}

Expand All @@ -152,7 +152,7 @@ void piiOnlyGrant_createsOnlyCustomerSubscription_noResourceUri() {
assertThat(result.resourceSubscriptionId()).isNull();
assertThat(result.resourceUri()).isNull();
assertThat(result.customerSubscriptionId()).isNotNull();
assertThat(result.customerResourceUri()).isEqualTo(PII_CUSTOMER_URI);
assertThat(result.customerResourceUri()).isEqualTo(BASE_URI + "/Batch/RetailCustomer/" + CUSTOMER_ID);

AuthorizationEntity saved = captureSavedAuthorization();
assertThat(saved.getResourceURI()).isNull();
Expand Down
Loading
Loading