feat(#122 PR C3): AS-side delegation to DC + /oauth2/authorize/continue#144
Merged
Conversation
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>
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
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/authorizewhen 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.handoff→openespi-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'sloginPage/consentPageredirect, 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'sredirect_uriandstatefor the return trip.AuthorizationServerConfigwiresconsentPage("/authorize/delegate")+ aLoginUrlAuthenticationEntryPointfor text/html media types. Both endpoints permitAll'd in the default chain.C3.3 — Return endpoint (
/oauth2/authorize/continue)ROLE_CUSTOMER, saved viaHttpSessionSecurityContextRepository), pre-populateOAuth2AuthorizationConsentServicewith the customer'sapproved_scopesplit on;, 302 to/oauth2/authorizewith original TP params — Spring AS mints the code transparently.redirect_uriwitherror=access_denied+ echoedstateper RFC 6749 §4.1.2.1. Blankapproved_scopeon an "allow" payload also falls through to deny semantics.@ExceptionHandler(InvalidHandoffException)returns uniformerror/400view (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).V7_0_0__create_handoff_nonces.sqlfor h2/mysql/postgresql (vendor-neutral TIMESTAMP DDL, identical to handoff module's canonical V4 — AS's Flyway is per-vendor only and doesn't scanclasspath:db/migration).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
openespi-handoffmoduleGET /oauth2/authorize/continueerror=access_denied+ echo stateTest plan
mvn teston openespi-handoff (handoff module tests) — passesmvn test -Dtest='*Delegate*,*Continue*,*DelegationState*,*Outbound*'on AS — 22/22 passNotes / Known limitations
DelegationStateServiceis in-memory — fine for sandbox/single-instance deployments, not for horizontal scaling. Documented inline; would back with Redis or a JDBC table in production.UserDetailsService; it only needs the user authenticated for Spring AS's code mint. The DC-assigned id will appear inOAuth2Authorization.principalNameand introspection responses.AuthorizationServerApplicationTests,*TestcontainersIntegrationTest, etc.) all root-cause toH2: Unknown data type: "DATETIME"fromV1_0_0__create_oauth2_schema.sql— predates C3, unrelated to this PR.🤖 Generated with Claude Code