Skip to content

feat(#122 PR C3): AS-side delegation to DC + /oauth2/authorize/continue#144

Merged
dfcoffin merged 2 commits into
mainfrom
feature/issue-122-pr-c3-as-delegation
Jun 3, 2026
Merged

feat(#122 PR C3): AS-side delegation to DC + /oauth2/authorize/continue#144
dfcoffin merged 2 commits into
mainfrom
feature/issue-122-pr-c3-as-delegation

Conversation

@dfcoffin
Copy link
Copy Markdown
Contributor

@dfcoffin dfcoffin commented Jun 3, 2026

Summary

Closes the loop on the customer-facing OAuth2 flow for #122. Wires the GBA Authorization Server into the DC-hosted Authorization Screen (PR C2b) via a signed-handoff round-trip: AS delegates login + consent to DC, then resumes /oauth2/authorize when the customer returns.

This is the AS-side counterpart to PRs C1/C2a/C2b. After this lands, the only remaining piece for full delegation is PR C4 (AS calls DC's back-channel #139 endpoint on grant-success + augments the token response).

What's in this PR

C3.1 — Module extraction (openespi-handoff)
Pivoted from the original "mirror the codec into AS" plan after reconsideration: a shared Maven module is cleaner DRY without violating the long-standing authserver-independent-of-common-domain invariant. The codec is pure infrastructure (HMAC + Jackson + JPA, no ESPI types). All handoff code moved from openespi-common.handoffopenespi-handoff.handoff; AS depends on it directly, common transitively.

C3.2 — Outbound delegation

  • OutboundHandoffBuilder — composes AS→DC signed outbound, 5-min TTL, returnUrl from the issuer URI.
  • AuthorizeDelegateController (GET /authorize/delegate) — receives Spring AS's loginPage/consentPage redirect, signs the handoff, 302s user-agent to DC's /oauth/authorize-screen.
  • DelegationStateService — in-memory ConcurrentMap (sandbox-only; production would back with Redis/JDBC) keyed by correlation_id, storing the TP's redirect_uri and state for the return trip.
  • AuthorizationServerConfig wires consentPage("/authorize/delegate") + a LoginUrlAuthenticationEntryPoint for text/html media types. Both endpoints permitAll'd in the default chain.

C3.3 — Return endpoint (/oauth2/authorize/continue)

  • Allow path: verify signed return, consume nonce, authenticate customer in AS session (opaque DC-assigned principal, ROLE_CUSTOMER, saved via HttpSessionSecurityContextRepository), pre-populate OAuth2AuthorizationConsentService with the customer's approved_scope split on ;, 302 to /oauth2/authorize with original TP params — Spring AS mints the code transparently.
  • Deny path: 302 to TP's redirect_uri with error=access_denied + echoed state per RFC 6749 §4.1.2.1. Blank approved_scope on an "allow" payload also falls through to deny semantics.
  • Invalid handoff: @ExceptionHandler(InvalidHandoffException) returns uniform error/400 view (no info leak); internal log captures specific cause (malformed, expired, wrong direction, nonce replay, missing delegation state).

C3.4 — Config + Flyway + tests

  • application.yml: espi.handoff.signing-key (HMAC, ≥32 chars, must match DC), espi.datacustodian.baseUrl (customer-browser-reachable).
  • Per-vendor V7_0_0__create_handoff_nonces.sql for h2/mysql/postgresql (vendor-neutral TIMESTAMP DDL, identical to handoff module's canonical V4 — AS's Flyway is per-vendor only and doesn't scan classpath:db/migration).
  • 22 new tests, all passing:
    • DelegationStateServiceTest (4) — save/consume/peek semantics + null tpState.
    • OutboundHandoffBuilderTest (4) — round-trip via real codec, TTL, issuedAt, cid overload.
    • AuthorizeDelegateControllerTest (3) — state-save under TP-state-as-correlation-id, redirect_uri fallback to RegisteredClient, missing-state generates fresh cid.
    • AuthorizeContinueControllerTest (11, nested) — Allow/Deny/Invalid handoff paths.

Design decisions pinned this PR

Question Choice Why
Codec location Shared openespi-handoff module DRY without coupling AS to common's domain (pure infra utility, no ESPI types)
Return endpoint shape Custom GET /oauth2/authorize/continue Spring AS doesn't expose a clean post-consent resume hook — handle it ourselves
Deny semantics RFC 6749 §4.1.2.1 error=access_denied + echo state Spec-compliant; TPs already handle this code path

Test plan

  • mvn test on openespi-handoff (handoff module tests) — passes
  • mvn test -Dtest='*Delegate*,*Continue*,*DelegationState*,*Outbound*' on AS — 22/22 pass
  • CI green
  • Manual: bring up AS + DC together, walk through a full auth-code flow with allow path
  • Manual: deny path delivers correct error=access_denied to TP
  • Manual: replay an already-consumed handoff → 400

Notes / Known limitations

  • DelegationStateService is in-memory — fine for sandbox/single-instance deployments, not for horizontal scaling. Documented inline; would back with Redis or a JDBC table in production.
  • Customer principal is opaque on the AS — the AS does not maintain a RetailCustomer table or UserDetailsService; it only needs the user authenticated for Spring AS's code mint. The DC-assigned id will appear in OAuth2Authorization.principalName and introspection responses.
  • Pre-existing test failures unchanged: 30 failures + 86 errors in integration tests (AuthorizationServerApplicationTests, *TestcontainersIntegrationTest, etc.) all root-cause to H2: Unknown data type: "DATETIME" from V1_0_0__create_oauth2_schema.sql — predates C3, unrelated to this PR.

🤖 Generated with Claude Code

dfcoffin and others added 2 commits June 3, 2026 01:44
Wires the Authorization Server into the Authorization-Screen flow shipped in
PR C2b. When Spring AS's filter chain decides the customer must authenticate
or grant consent, it now redirects to a DC-hosted screen via a signed handoff,
then resumes the OAuth2 flow on the customer's return.

Module extraction (C3.1):
* Extract openespi-handoff as a new Maven module — the codec is pure
  infrastructure (HMAC + Jackson + JPA), and a shared module eliminates the
  mirror-and-drift risk without violating the long-standing
  authserver-independent-of-common-domain invariant. openespi-handoff carries
  no ESPI domain types.
* Move SignedHandoff, SignedHandoffCodec, HandoffNonceEntity/Repository/
  Service, InvalidHandoffException, and the V4 nonce-table migration from
  openespi-common/handoff into openespi-handoff/handoff (package renamed).
* AS depends on openespi-handoff directly; common depends on it transitively
  for the existing DC consumers (AuthorizeScreenController/Service).

AS-side delegate flow (C3.2 + C3.3):
* OutboundHandoffBuilder — composes the AS->DC signed payload, 5-minute TTL,
  returnUrl derived from spring.security.oauth2.authorizationserver.issuer.
* AuthorizeDelegateController (GET /authorize/delegate) — receives Spring AS's
  loginPage/consentPage redirect, saves the third party's redirect_uri and
  state into DelegationStateService keyed by correlation id (TP state or fresh
  UUID), redirects user-agent to DC's /oauth/authorize-screen with the signed
  token. Falls back to the RegisteredClient's first registered redirect_uri
  when Spring AS's consentPage redirect omits it.
* DelegationStateService — in-memory ConcurrentMap with 5-minute TTL and lazy
  reaping, single-use consume(). Documented as sandbox-only; production would
  back with Redis or JDBC.
* AuthorizeContinueController (GET /oauth2/authorize/continue) — verifies the
  signed return handoff, consumes the nonce single-use, then either:
  - Allow: authenticates the customer in the AS session (UsernamePassword
    AuthenticationToken with ROLE_CUSTOMER + HttpSessionSecurityContext
    Repository), pre-populates OAuth2AuthorizationConsentService with the
    customer's effective approved_scope split on ';', and 302s to
    /oauth2/authorize with the original TP params so Spring AS mints the code
    transparently.
  - Deny: 302s to the TP's redirect_uri with error=access_denied per RFC 6749
    section 4.1.2.1, echoing the original state. Blank approved_scope on an
    "allow" payload also falls through to deny semantics.
  - Invalid handoff (malformed / expired / wrong direction / nonce replay /
    missing delegation state): @ExceptionHandler returns a uniform error/400
    view with no information leak; internal log captures the specific cause.
* AuthorizationServerConfig wires consentPage("/authorize/delegate") and a
  LoginUrlAuthenticationEntryPoint for text/html media types pointing at the
  same delegate endpoint; permitAll for /authorize/delegate and
  /oauth2/authorize/continue in the default chain.
* Uniform error/400.html template (Bootstrap, no info leak).

Config + Flyway + tests (C3.4):
* application.yml: espi.handoff.signing-key (HMAC, >=32 chars, must match DC),
  espi.datacustodian.baseUrl (customer-browser-reachable, used by
  AuthorizeDelegateController).
* Per-vendor handoff_nonces migrations at V7_0_0 for h2/mysql/postgresql.
  Vendor-neutral TIMESTAMP DDL, identical to the openespi-handoff module's
  canonical V4 (AS's Flyway is configured per-vendor only and does not scan
  classpath:db/migration, so the table ships independently here).
* 22 new tests:
  - DelegationStateServiceTest (4) — save/consume/peek + null-tpState.
  - OutboundHandoffBuilderTest (4) — codec round-trip via the real codec,
    5-minute TTL, recent issuedAt, correlation-id overload.
  - AuthorizeDelegateControllerTest (3) — happy path saves state under TP
    state as correlation id, redirect_uri fallback to RegisteredClient,
    missing state generates fresh correlation id.
  - AuthorizeContinueControllerTest (11, nested classes) — Allow path
    pre-populates consent and redirects; Deny path delivers access_denied +
    state per RFC 6749 section 4.1.2.1; Invalid handoff path returns
    error/400 on malformed/expired/replay/missing-state/missing-param with no
    consent write and no nonce service interaction on malformed input.

Notes:
* Customer principal on the AS side is opaque (the DC-assigned id from the
  return handoff). The AS does not maintain its own RetailCustomer table or
  UserDetailsService — it only needs the user authenticated for code mint.
* DataCustodianApplication + common's TestApplication scanBasePackages,
  @EntityScan, and @EnableJpaRepositories updated to include the new
  org.greenbuttonalliance.espi.handoff package.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The new openespi-handoff module (introduced this PR) is an upstream
dependency of openespi-common. Without -am ("also make"), the targeted
mvn test runs in pr-checks.yml and ci.yml's integration-test step attempt
to resolve OpenESPI-Handoff:3.5.0-RC2 from maven.pkg.github.com — which
returns 401 because the artifact has never been published there. Adding
-am makes Maven build openespi-handoff first and install it locally so
the test compile can proceed.

ci.yml's unit-test steps already use -am and were unaffected; ci.yml's
"Build all modules" precursor also masked the issue for the main build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dfcoffin dfcoffin merged commit 9a86686 into main Jun 3, 2026
4 checks passed
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