feat(#122 PR C1): signed-handoff codec + nonce table foundation#140
Merged
Conversation
Adds the tamper-evident value object the GBA Authorization Server and a
Data Custodian sandbox will exchange via URL parameter during the
customer-facing OAuth2 flow (replaces a shared Spring Session as the
cross-app state mechanism). Mechanism only — AS and DC sides that
*use* this land in subsequent PRs (C2a, C2b, C3, C4).
Wire format (two dot-separated base64URL segments):
{base64URL(JSON(payload))} . {base64URL(HMAC-SHA256(key, payload))}
Direction tag in the payload (outbound | return) prevents one direction's
token from being replayed as the other. Single-use nonce table on the
receiver prevents replay within a direction. Short expiry (5 min default).
Codec — openespi-common.handoff
- SignedHandoff sealed interface + Outbound / Return records
(snake_case JSON via Jackson 3.x).
- SignedHandoffCodec — HMAC-SHA256 encode + verify; rejects malformed,
tampered (constant-time compare), wrong-direction, wrong-version,
expired payloads. Constructor-injected signing key (≥32 chars).
- InvalidHandoffException — uniform rejection signal (callers must not
reveal which sub-check failed to the user-agent).
Replay protection
- HandoffNonceEntity implements Persistable<String> with isNew() == true
so JpaRepository.save() routes to entityManager.persist() (INSERT-only),
NOT merge(); a duplicate consume surfaces as PK violation rather than
silently UPDATEing the existing row.
- HandoffNonceService.consume runs in Propagation.REQUIRES_NEW: a
partially-completed grant must not allow the same nonce to be reused
even if the surrounding business transaction rolls back.
- V4__Create_Handoff_Nonces.sql — vendor-neutral DDL (H2 / MySQL /
PostgreSQL).
Scan-path wiring
- DataCustodianApplication and TestApplication EntityScan +
EnableJpaRepositories now include the handoff package.
- application.yml documents espi.handoff.signing-key (default for dev;
ESPI_HANDOFF_SIGNING_KEY env var in production).
AS-side mirror
- Deferred to PR C3 where the AS actually starts using the codec. C1
ships only what DC consumers in C2a/C2b will need.
Tests
- SignedHandoffCodecTest — 12 unit tests: round-trip both directions,
tampered-payload / tampered-signature / wrong-key rejection (all via
constant-time compare), expiry, wrong-direction, wrong-version,
malformed / empty token, short signing key.
- HandoffNonceServiceTest — 6 @DataJpaTest cases: first-consume success,
replay rejection (PK violation), distinct-nonces independence,
uniqueness over 10k generates, blank-nonce rejection, reaper sweep.
Assertions are by id-lookup (not row count) because REQUIRES_NEW
commits escape the @DataJpaTest rollback.
Verification
- openespi-common handoff tests: 18 / 18 pass.
- DataCustodianApplicationH2Test (full SpringBootTest context): 3 / 3
pass — confirms the Spring auto-wiring of the dual-constructor codec
(the public ctor is @Autowired; the package-private one is for tests).
- openespi-datacustodian full suite: BUILD SUCCESS (97 / 97 + 1 pre-
existing @disabled skip).
Refs: #122. Builds on PR A (#136), PR B1 (#137), PR B2 (#139).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7 tasks
dfcoffin
added a commit
that referenced
this pull request
Jun 1, 2026
Brings DC's dormant customer-facing login form online. The RetailCustomerEntity already has password / role / enabled / lockout columns and the schema migration is complete (V2 vendor-specific tables) — this PR finishes what was started: re-enables the inert LoginController, wires a SecurityFilterChain backed by a UserDetailsService that maps the existing `role` column to Spring Security authorities, and makes the long-existing custodian admin UI (RetailCustomerController + @PreAuthorize("hasRole('ROLE_CUSTODIAN')")) actually reachable. Customer-login filter chain (openespi-datacustodian) - CustomerLoginSecurityConfiguration — third SecurityFilterChain @order(0), between BackchannelSecurityConfiguration (HIGHEST_PRECEDENCE /internal/**) and the public OAuth2 resource-server chain (@order(1) /espi/**). Matches /login, /logout, /custodian/**, /oauth/authorize-screen/** (latter mounted now for PR C2b's Authorization Screen). - formLogin with BCrypt, IF_REQUIRED session, CSRF default (HttpSessionCsrfTokenRepository — right shape for vanilla form POSTs; CookieCsrfTokenRepository is for SPAs). - PathPatternRequestMatcher.withDefaults() — the Spring Security 7.x replacement for the removed AntPathRequestMatcher. - SimpleUrlAuthenticationSuccessHandler honors a `return_to` form parameter (absolute URL or same-origin path) so the AS-issued signed handoff from PR C1 round-trips through login. isSafeReturnTo() rejects open-redirect targets (`//evil.example.com`) defense-in-depth above the codec's own signature verification. UserDetailsService - RetailCustomerUserDetailsService (DC, not common — security adapters belong in the consuming web layer, not the persistence layer) — looks up by username, returns Spring User principal with authorities derived from the `role` column, honors `enabled` and `account_locked` flags. - Single-entity role-discriminator model (the codebase had already chosen this path; greenfield would prefer split entities, but the refactor cost outweighs the harm). Login UI - LoginController re-enabled: @controller stereotype activated, GET accepts `?return_to=<signed-handoff>` and exposes it to the template. - login.html cleanup: removed dead Google/GitHub OAuth client buttons (ESPI is utility-owned customer identity; social login is wrong architecture and out of scope), removed dead /register link, added hidden return_to input. CSRF token auto-injected by Thymeleaf's th:action. Admin password handling - RetailCustomerController.create POST now BCrypt-hashes the cleartext password before save (was previously persisting raw, the @PreAuthorize meant this code path was unreachable so it was latent rather than exploited). - Dropped misplaced @pattern regex from RetailCustomerEntity.password — the constraint was validating the cleartext-password regex against the bcrypt hash (which contains `.` and `/` chars excluded from any sensible cleartext charset), so it rejected every successfully-hashed password at JPA persist time. Cleartext strength belongs on form input via PasswordPolicy, not on the stored hash. Dev sandbox seed - DevSandboxAdminSeedRunner — @Profile-gated (dev-mysql, dev-postgresql, local, test, testcontainers) CommandLineRunner that idempotently inserts one admin (username='admin', bcrypt('admin'), role='ROLE_CUSTODIAN') if absent. Out-of-Flyway so production deployments never seed; the bcrypt cleartext is harmless in source because production deployments don't activate dev profiles. Authorization API security boundary (revives @disabled test) - SecurityConfiguration: /espi/1_1/resource/Authorization/** now accepts hasAnyAuthority(SCOPE_DataCustodian_Admin_Access, SCOPE_ThirdParty_Admin_Access). The endpoint exposes OAuth2 metadata, not customer data — admin (DC client_credentials) and client (TP client_credentials) tokens both legitimately need it; customer FB-scoped tokens (authorization_code flow output) must never reach it. - AuthorizationController @PreAuthorize on both methods updated to match. - AuthorizationControllerTest revived (was @disabled since PR #116) and rewritten as a security-boundary-only test: 401 unauthenticated, 403 for customer FB scopes (SCOPE_FB_15_*, SCOPE_FB_54_*), pass-the-gate for both DC_Admin and TP_Admin. Body content is NOT asserted — the stub bodies return null and need implementation (#141 filed). Verification - RetailCustomerUserDetailsServiceTest: 6 / 6 unit tests pass. - CustomerLoginSecurityConfigurationTest: 7 / 7 MockMvc tests pass (unauthenticated GET, good admin / customer creds, bad creds, CSRF required, return_to honored, open-redirect rejected). - AuthorizationControllerTest: 9 / 9 security-boundary tests pass. - openespi-datacustodian full suite: 118 / 118 pass, 0 skipped (previously 97 + 1 @disabled skip — +21 net, +1 skip reclaimed). - openespi-common full suite: 866 / 866 pass. Refs: #122. Builds on PR A (#136), PR B1 (#137), PR B2 (#139), PR C1 (#140). Follow-up: #141 (AuthorizationController stub bodies + per-TP filtering). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
7 tasks
dfcoffin
added a commit
that referenced
this pull request
Jun 3, 2026
…143) Builds the customer-facing OAuth2 consent screen that the AS will redirect to after PR C3 wires up consentPage(). The screen renders exactly what the third party requested — driven entirely by the FB catalog from PR A — and accepts per-FB granular consent decisions (GDPR-style opt-in for sensitive PII categories). Driven by the XSLT conformance reference at D:\Dropbox\gbwebsites\dmdvalidator.greenbuttonalliance.org\conformance (see issue #141 for the full per-FB mapping table). FunctionBlock role helpers (openespi-common.scope) - Four new predicates classify each FB by its consent-UI role: isImplicitBase() — FB 1/4/51 (structural prerequisites, always implicit, never customer-selectable) isCommodityProfile() — FB 5/6/7/8/9/10/11/29 (drives the commodity sections; profile within a commodity is display-only) isDataShapeModifier() — FB 12/15/16/17/27/28 (display-only modifier on each commodity section) isPiiSelectable() — FB 54–62 (individually toggleable consent checkbox, default-unchecked) - Both instance and static (int id) forms for use with raw FB integers from EspiScope. Behavior on the enum; localized text in the i18n bundle (canonical Spring separation). i18n bundle (openespi-datacustodian/src/main/resources/messages.properties) - ~30 keys: fb.NN.label / fb.NN.description per FB, commodity.* section titles, screen.* UI strings, error.* page strings. English-only for the sandbox; production deployments drop locale-specific overrides (messages_<lang>.properties). - Spring Boot's default MessageSource auto-loads it; Thymeleaf th:text="#{key}" picks it up natively; controller uses MessageSource.getMessage(...) for pre-localized view model fields. SignedHandoff.Return.approvedScope (openespi-common.handoff) - Additive field carrying the customer's effective scope (subset of the originally-requested scope, narrowed by the customer's checkbox decisions). The AS mints the access token with this scope, NOT the original — so the TP receives exactly what the customer agreed to. - Codec version stays at 1: purely additive, old readers see null. Authorization Screen (openespi-datacustodian.web.authorize) - AuthorizeScreenController @ Controller mounted at /oauth/authorize-screen GET — decodes + verifies the AS-issued outbound handoff (PR C1 codec), consumes the nonce (single-use replay protection), validates the granted scope is a subset of the TP's registered ApplicationInformation.scope (defense in depth; AS is supposed to enforce this at /authorize, DC re-verifies), builds the view model, renders authorize-screen.html. POST — re-verifies the handoff (round-tripped through the form), computes the effective approvedScope from the customer's checkbox decisions, builds a SignedHandoff.Return, redirects user-agent to outbound.returnUrl with the signed return token. - AuthorizeScreenService — pure business logic so the FB-subset check and scope-narrowing computation are unit-testable without spinning up Spring web context. Implements per-commodity grouping, per-FB PII consent, and the implicit-base auto-inclusion rule (FB 1+4 always present when any commodity remains in the approved scope; FB 51 always present when any PII remains). - AuthorizeScreenViewModel — immutable record + nested CommoditySection, UsagePointChoice, PiiOption sub-records. All strings pre-localized in the service; template renders raw values. - @ExceptionHandler(InvalidHandoffException) — uniform 400 + internal log. Catches ALL handoff failure modes (bad signature, expired, replayed nonce, unknown client_id, scope escalation) and returns the existing templates/error/400.html. Internal log captures the specific sub-cause for security audit; external response reveals nothing about which check failed (don't give attackers an oracle). HTTP 400, not 401/403; no redirect; no echo of attacker-controlled input. Per OWASP guidance for verification-failure responses. authorize-screen.html — Bootstrap 5 + Thymeleaf - Energy-data section: one commodity sub-section per ServiceKind requested. Customer toggles individual usage points (default-checked — TP explicitly requested the commodity). Profile labels (e.g. "Hourly delivered", "Solar export") and data-shape labels (e.g. "Billing-period summaries", "Power quality") render as display-only "this application will receive" bullets. - Personal information section: one checkbox per PII FB in the requested scope, default-unchecked, with the XSLT-verified label + description. - Footer note for implicit-base FBs. - Allow / Deny submit buttons; "Allow with no selections" falls back to deny semantics (consent=deny, approvedScope=null). - CSRF token auto-injected by th:action; signed handoff round-tripped via hidden field. Tests - FunctionBlockRoleHelpersTest — 35 parameterized tests covering all 17 active screen-relevant FBs + representative non-screen and unknown/deprecated FBs. - AuthorizeScreenServiceTest — 13 unit tests (Mockito + StaticMessageSource): client+scope validation passes for registered subset; rejects unknown client_id; rejects scope escalation; commodity grouping; PII per-FB rendering; scope-narrowing for commodity / PII / mixed / deny cases; implicit-base auto-inclusion. - AuthorizeScreenControllerTest — 8 MockMvc @SpringBootTest cases: invalid handoff (5 sub-causes) all → 400; happy GET renders form with TP name + CSRF + handoff hidden field; POST allow → 302 with signed Return handoff carrying narrowed approvedScope; POST deny → 302 with consent=deny; POST allow with nothing checked → falls back to deny. Verification - openespi-common full suite: 901 / 901 pass (was 866; +35 from FunctionBlockRoleHelpersTest). - openespi-datacustodian full suite: 139 / 139 pass, 0 skipped (was 118; +21 from new authorize tests). Refs: #122. Builds on PR A (#136), PR B1 (#137), PR B2 (#139), PR C1 (#140), PR C2a (#142). XSLT mapping reference: #141. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the tamper-evident value object the GBA Authorization Server and a Data Custodian sandbox will exchange via URL parameter during the customer-facing OAuth2 flow (Step 3.5 of #122). Replaces a shared Spring Session as the cross-app state mechanism — see the architectural rationale below.
Mechanism only — AS and DC sides that use this land in subsequent PRs (C2a customer login, C2b Authorization Screen, C3 AS delegation, C4 back-channel + token augmentation).
Why signed-redirect-parameter, not shared Spring Session
The AS and DC are independently deployed Spring Boot applications. A shared JDBC/Redis Spring Session would force:
The signed-handoff approach borrows the same idea OAuth2 itself uses for the
stateparameter: encode the cross-app state in the URL, sign it with HMAC, let each side keep its own session. Replay is prevented by a single-use nonce table on the receiver. Every major OAuth2 provider (Google, GitHub, Microsoft, Auth0, Keycloak, Okta, Spring Authorization Server samples) renders their consent flow server-side with redirect-mediated state, not via shared session — same reasoning.Wire format
Two dot-separated base64URL segments:
Payload (snake_case JSON via Jackson 3.x):
v— wire-format version (currently 1)dir—"outbound"(AS→DC) or"return"(DC→AS)cid— opaque correlation id (echoed in both directions, traced in logs)iat/exp— issued-at and expires-at instants (5-minute default lifetime)nonce— base64URL 128-bit, receiver tracks single-useclient_id/scope/return_url(outbound) vs.sub/up/cust_uri/consent(return)Direction tag prevents one direction's token from being replayed as the other. Single-use nonce prevents replay within a direction.
What ships in this PR
openespi-common.handoffpackageSignedHandoff— sealed interface +Outbound/Returnrecords with.of()factory methods that fill in version + direction.SignedHandoffCodec— HMAC-SHA256 encode + verify; rejects malformed, tampered (viaMessageDigest.isEqualconstant-time compare), wrong-direction, wrong-version, expired payloads. Constructor-injected signing key (≥32 chars enforced).InvalidHandoffException— uniform rejection signal. Callers must NOT reveal which sub-check failed to the user-agent.HandoffNonceEntity— implementsPersistable<String>withisNew() == truesoJpaRepository.save()routes toentityManager.persist()(INSERT-only), NOTmerge()(UPSERT). A duplicate consume surfaces as a PK violation rather than silentlyUPDATEing the existing row — the bug we'd otherwise have if we relied on default Spring Data save() semantics with assigned-id entities.HandoffNonceService.consume— runs inPropagation.REQUIRES_NEW. A partially-completed grant must not allow the same nonce to be reused even if the surrounding business transaction rolls back.Migration
V4__Create_Handoff_Nonces.sql— vendor-neutral DDL (H2 / MySQL / PostgreSQL).Scan-path wiring
DataCustodianApplicationandTestApplication@EntityScan+@EnableJpaRepositoriesnow include thehandoffpackage.application.ymldocumentsespi.handoff.signing-key(default for dev;ESPI_HANDOFF_SIGNING_KEYenv var in production).Deferred to later PRs
openespi-common; the same codec + entity will be duplicated on the AS side, but only when C3 actually wires the AS to use it. Shipping it now would be dead code with no caller.Test plan
SignedHandoffCodecTest— 12 unit tests: round-trip both directions, tampered-payload / tampered-signature / wrong-key rejection (all via constant-time compare), expiry, wrong-direction, wrong-version, malformed / empty token, short signing key.HandoffNonceServiceTest— 6@DataJpaTestcases: first-consume success, replay rejection (PK violation), distinct-nonces independence, uniqueness over 10k generates, blank-nonce rejection, reaper sweep. Assertions are by id-lookup (not row count) becauseREQUIRES_NEWcommits escape the@DataJpaTestrollback.DataCustodianApplicationH2Test(full SpringBootTest context): 3 / 3 pass — confirms Spring auto-wires the dual-constructor codec (public ctor is@Autowired; package-private one is for tests).openespi-datacustodianfull suite: BUILD SUCCESS, 97 / 97 pass + 1 pre-existing@Disabledskip.Refs
🤖 Generated with Claude Code