Skip to content

feat(#122 PR C1): signed-handoff codec + nonce table foundation#140

Merged
dfcoffin merged 1 commit into
mainfrom
feature/issue-122-pr-c1-signed-handoff-codec
Jun 1, 2026
Merged

feat(#122 PR C1): signed-handoff codec + nonce table foundation#140
dfcoffin merged 1 commit into
mainfrom
feature/issue-122-pr-c1-signed-handoff-codec

Conversation

@dfcoffin
Copy link
Copy Markdown
Contributor

@dfcoffin dfcoffin commented Jun 1, 2026

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:

  • Cross-app cookie-domain alignment (SameSite=None ⇒ HTTPS required even in dev)
  • An ambiguous "whose authenticated principal lives in the shared session" answer
  • New infrastructure (Redis) or a shared session schema (JDBC) to provision and reap
  • A subtle security boundary between two apps that now share session state

The signed-handoff approach borrows the same idea OAuth2 itself uses for the state parameter: 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:

{base64URL(JSON(payload))} . {base64URL(HMAC-SHA256(key, payload))}

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-use
  • Direction-specific fields: client_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.handoff package

  • SignedHandoff — sealed interface + Outbound / Return records with .of() factory methods that fill in version + direction.
  • SignedHandoffCodec — HMAC-SHA256 encode + verify; rejects malformed, tampered (via MessageDigest.isEqual constant-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 — implements Persistable<String> with isNew() == true so JpaRepository.save() routes to entityManager.persist() (INSERT-only), NOT merge() (UPSERT). A duplicate consume surfaces as a PK violation rather than silently UPDATEing 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 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.

Migration

  • 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).

Deferred to later PRs

  • AS-side codec mirror — the AS module is deliberately independent of 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 @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.
  • 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-datacustodian full suite: BUILD SUCCESS, 97 / 97 pass + 1 pre-existing @Disabled skip.
  • CI: 3-DB integration tests (MySQL / PostgreSQL / H2 via TestContainers).
  • CI: Security vulnerability scan + SonarCloud.

Refs

🤖 Generated with Claude Code

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>
@dfcoffin dfcoffin merged commit 71545e5 into main Jun 1, 2026
4 checks passed
@dfcoffin dfcoffin deleted the feature/issue-122-pr-c1-signed-handoff-codec branch June 1, 2026 02:15
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant