Repository navigation
Phase 2.0 — Stand up auth-server in dev with opaque tokens + subscription-bound introspection (Phase 2 precursor) #122
Description
Precursor to #119 Phase 2.
Phase 2 (subscription scoping / data-leakage fix) cannot ship until the
authorization server is running, issues opaque tokens per ESPI 4.0, and exposes
a subscription_id-bearing introspection response. Until then every Phase 2a
design choice is unverifiable against a hypothetical token shape.
See the design analysis posted as a comment on #119:
#119 (comment)
Why this is blocking
ESPI 4.0 requires opaque (reference) access tokens — JWTs are not permitted by
the spec. A resource server cannot read claims from an opaque token; it must
call RFC 7662 token introspection to learn what the token grants. Phase 2's
entire OpaqueTokenIntrospector / ApiAccessValidator design is built around
that introspection response. Without a running auth-server returning the
expected response shape (including subscription_id), no Phase 2 code can be
end-to-end tested.
Shipping a security fix that can't be proven to work against a real token is
the worst outcome for an IDOR / data-leakage fix.
Acceptance criteria
Phase 2.0 is complete when one integration test boots both modules, mints a
real token via the authorization code flow, calls a data-custodian endpoint,
and asserts the principal carries subscription_id. Until that test passes,
no Phase 2a code will be merged.
Checklist
Step 1 — Configuration audit (read-only)
- Find
RegisteredClient/OAuth2AuthorizationServerConfiguration; confirmtokenSettings()format (REFERENCE vs SELF_CONTAINED) - Confirm
/oauth2/introspectis enabled and properly authenticated (client credentials, not bearer) - Find any
OpaqueTokenIntrospector/OAuth2TokenCustomizerbeans - Inspect
OAuth2Authorizationpersistence; locate where subscription binding could be stored at grant time - Verify
oauth2_authorizationtable exists; check for custom columns or join tables linking authorizations to subscriptions - Post audit findings as an issue comment (what is configured vs what Phase 2 needs)
Step 2 — Local dev environment
- Bring up MySQL (
dev-mysqlprofile) - Boot
openespi-authserverwithdev-mysql; resolve any Flyway migration drift - Verify
/oauth2/.well-known/oauth-authorization-serverreturns a valid metadata document - Register at least one
RegisteredClient(third-party app) and one retail customer via seed data
Step 3 — Verify token format
- Run authorization code flow end-to-end (manual
curl/ Postman) - Inspect the issued access token: opaque (short, random) vs JWT (
eyJ...) - If JWT, switch
RegisteredClienttoOAuth2TokenFormat.REFERENCEand re-test
Step 4 — Add subscription_id to introspection response (the wildcard)
- Try Approach B first: customize auth-server
OAuth2TokenIntrospectionAuthenticationProvider/ converter to addsubscription_idto the introspection claims - If Spring Authorization Server doesn't expose a clean hook, fall back to Approach A: custom resource-server
OpaqueTokenIntrospectorthat enriches principal via DB lookup - Document which approach was used and why
Step 5 — Wire data-custodian as resource server
- Add
spring.security.oauth2.resourceserver.opaquetoken.*config pointing at auth-server introspection URL - Register
resource-serveras aRegisteredClientwithclient_credentialsgrant + introspection authority - Disable any existing JWT resource-server config (mutually exclusive on the same filter chain)
Step 6 — End-to-end smoke test
- Write one integration test that boots both modules, mints a real token via authorization code flow, calls a data-custodian endpoint, asserts the principal carries
subscription_id - Test passes → Phase 2.0 complete → Phase 2a unblocked
Non-goals (deferred)
- Scope-grammar alignment — Phase 7. Phase 2 keeps the existing
SCOPE_FB_*shorthand and pins parsing behind a single helper for later
swap. - JSON output — Phase 3. Cosmetic
produces = JSONdeclarations stay as-is. - Phase 2a/2b/2c code — does not start until step 6 passes.
Estimate
1–2 working days for a senior Spring developer who knows Spring Authorization
Server. Step 4 is the wildcard (Spring Authorization Server's
OAuth2TokenIntrospectionAuthenticationProvider extensibility is the unknown).
🤖 Generated with Claude Code
Activity
dfcoffin commentedon May 22, 2026
Step 1 — Configuration audit findings
Read-only audit complete. The picture is much better than expected for some
items, and exposes a couple of issues that change Phase 2's design.
Confirmed (work already done)
OAuth2TokenFormat.REFERENCE).AuthorizationServerConfig.java:254-300— all three default clients(
data_custodian_admin,third_party,third_party_admin) setaccessTokenFormat(OAuth2TokenFormat.REFERENCE). Default profile(
application.yml:96) also setsespi.token.format: opaque. No JWTconfiguration anywhere on the access-token path.
AuthorizationServerConfig.java:378—AuthorizationServerSettingsdeclares.tokenIntrospectionEndpoint("/oauth2/introspect").openespi-datacustodian/.../config/SecurityConfiguration.java:188-228buildsthe
SecurityFilterChainwith.opaqueToken(opaque -> opaque.introspectionUri(...).introspectionClientCredentials(...))and exposes an
OpaqueTokenIntrospectorbean usingSpringOpaqueTokenIntrospector.withIntrospectionUri(...).application.yml:86-94provides the matching config keys.JdbcOAuth2AuthorizationService.AuthorizationServerConfig.java:328-330. Writes tooauth2_authorization(schema in
V1_0_0__create_oauth2_schema.sql).AuthorizationServerConfig.java:276-277registers"FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13"andthe 900-second variant as
RegisteredClientscopes.Critical finding: scope-grammar mismatch is real and immediate
Auth-server issues spec-compliant scopes like
FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13.Data-custodian's
@PreAuthorizelines (in every controller) expectSCOPE_FB_15_READ_3rd_party,SCOPE_FB_16_READ_3rd_party, etc.These do not match. Spring Security prefixes each scope value with
SCOPE_when converting to
GrantedAuthority. The actual authority on theauthentication will be something like
SCOPE_FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13,which will never satisfy the
hasAuthority('SCOPE_FB_15_READ_3rd_party')predicate. Every legitimate non-admin request will return
403today onceintrospection actually runs end-to-end.
This was tagged as a "Phase 7" concern in the design analysis, but the audit
shows it is a Phase 2 blocker, not a parallel track. The introspection
layer can be perfect and still 100% of authenticated calls will 403 until the
scope grammar is aligned on both sides.
Critical finding: schema binding is customer_id + usage_point_ids, NOT subscription_id
V4_0_0__add_datacustodian_integration.sqladded an integration model thatbinds OAuth2 authorizations to data-custodian context:
oauth2_authorization.datacustodian_customer_id— retail customeroauth2_authorization.datacustodian_grant_id— grant IDdatacustodian_integration_mappingtable — linksauthorization_id→customer_id+usage_point_ids(JSON array of UUIDs)There is no
subscription_idcolumn anywhere in the auth-server schema.The ESPI Atom-feed concept of a "Subscription" — the
/Subscription/{id}/...path prefix — is data-custodian-local and maps to (customer + usage-point set),
not to a separate identity in the auth-server.
Phase 2 design implication: the principal carries
(customerId, usagePointIds[], scopes, isAdmin), notsubscriptionId. TheenforcePathMatchstep rewrites:pathSubscriptionIdmust resolve (viadata-custodian's own SubscriptionEntity or grant lookup) to a
(customerId, usagePointIds)tuple that matches the token's binding. The original Phase 2design document is correct in concept but wrong in identifier — needs revision
to the customer+usage-points model.
Critical finding: introspection response is NOT customized
The default
/oauth2/introspectresponse from Spring Authorization Servercarries
active,sub,scope,client_id,exp,iat,token_type,username,nbf,jti(RFC 7662). It does not carrydatacustodian_customer_idor any ESPI-specific binding.No
OAuth2TokenIntrospectionAuthenticationProvidercustomization exists. Nocustom
IntrospectionResponseextension exists. TheEspiTokenCustomizer.javabean (line 54+) isOAuth2TokenCustomizer<JwtEncodingContext>— JWT-only, and it short-circuits unless
espi.token.format=jwt. It isinert for the opaque-token path and cannot be reused for introspection-response
enrichment.
Approach decision: Step 4 of this issue's checklist must choose between:
OpaqueTokenIntrospectorwraps the default, readsprincipal_namefrom theintrospection response, then queries
oauth2_authorization(cross-schema orvia auth-server API) to look up
datacustodian_customer_idand the mappedusage_point_ids. Couples data-custodian to auth-server's database/API.custom
OAuth2TokenIntrospectionAuthenticationProvider(or a bean that wrapsit) that pulls
datacustodian_customer_idfrom the storedOAuth2Authorizationattributes and adds it to the introspection response.RFC 7662 permits custom claims (§3.1 last paragraph). Resource server gets
everything from one HTTP call.
Recommendation: Approach B. Spring Authorization Server 1.3+ exposes the
introspection extensibility via a security configurer (
OAuth2TokenIntrospectionEndpointConfigureror by replacing the default provider in the filter chain). Approach A's
cross-database coupling is a maintenance hazard and breaks if the two modules
ever move to separate deployments — which is the entire point of having two
modules.
Other observations
AuthorizationServerConfig.java:344-355generates an in-memory RSA key pair, even though no JWT path is active. This
is harmless but should be removed in cleanup (id_token signing for OIDC
still requires it; leave for now).
AuthorizationServerConfig.java:184-229— entiredefaultSecurityFilterChainbean is
// @Bean // @Order(2)and the body is commented inline. Theauthorization-server filter chain at order(1) handles everything. Not a
bug, but suggests the file needs a cleanup pass.
OAuth2Authorization—including
attributes(blob) andaccess_token_metadata(blob) — butcustom claims placed in those attributes are NOT surfaced through the
default introspection response. Approach B has to read them back and add
them explicitly.
Revised Phase 2.0 effort estimate
OAuth2TokenIntrospectionAuthenticationProvider; needs to surfacedatacustodian_customer_id+usage_point_idsOpaqueTokenIntrospectorbean for one that recognizes the enriched response shape and builds an ESPI principalSCOPE_FB_15_*shorthand in every controller's@PreAuthorizewith a parser-based check against the ESPI 4.0 FB-string grammar. Blocker for Phase 2a; cannot proceed without it.Revised total: 2–3 working days (vs. 1–2 in the original estimate). The
scope-grammar alignment is the surprise.
Recommended next steps
acceptance criterion (alignment must work end-to-end in the smoke test).
endpoint against one of the seeded clients to confirm the actual
default response shape — the audit infers it from defaults, not from a live
run.
dfcoffin commentedon May 24, 2026
Correction — amendment to #issuecomment-4520974698
Two findings from the Step 1 audit were wrong. Posting this as a standalone correction so the audit trail is preserved.
What I got wrong
Claim (in prior comment): "There is no
subscription_idcolumn anywhere in the auth-server schema… Phase 2 design implication: the principal carries(customerId, usagePointIds[], scopes, isAdmin), notsubscriptionId."Reality: I was looking in the wrong place.
SubscriptionEntityalready exists as the canonical model — owned by data-custodian (viaopenespi-common), not by auth-server:openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/usage/SubscriptionEntity.java:53-119@Id UUID id— the/Subscription/{id}/...handle exposed on the wire@ManyToOne RetailCustomerEntity retailCustomer— who owns the data@OneToOne AuthorizationEntity authorization— the OAuth2 authorization (1:1)@ManyToOne ApplicationInformationEntity applicationInformation— which third party@ManyToMany List<UsagePointEntity> usagePointsviasubscription_usage_pointsjoin table — authorized FB resourcesisActive(),isExpired(),isRevoked(),includesUsagePoint(...),belongsToCustomer(...)The auth-server schema's lack of a
subscription_idcolumn is not a gap — auth-server doesn't own subscriptions.SubscriptionEntityis created in data-custodian at OAuth grant time and represents the access token + customer + authorized data resources. A customer can have multiple third-party relationships, and a single third party may hold multiple scope strings against the same customer (e.g., electric, gas, both) — each is a distinctSubscriptionEntity.Revised principal model
EspiPrincipal(customerId, usagePointIds[], scopes, isAdmin)EspiPrincipal(subscriptionId, isAdmin)Everything else is derived locally in data-custodian via
SubscriptionRepository.findById(subscriptionId)→(retailCustomer, usagePoints, applicationInformation, authorization). No need to carry customer/usage-point arrays across the introspection wire.Enforcement patterns collapse to two checks:
/Subscription/{subId}/UsagePoint/...): assertpathSubId.equals(principal.subscriptionId)— single string compare.subscriptionRepository.findById(principal.subscriptionId).getUsagePoints()— return 404 (not 403) for anything outside that set, to avoid existence-leak.Revised introspection enrichment (simpler)
Approach B (auth-server-side customization) still wins, but the custom claim shrinks:
One UUID. Data-custodian's
OpaqueTokenIntrospectorwrapper readssubscription_idand builds the principal; everything else is a local DB lookup. No customer-id or usage-point arrays on the wire.Auth-server work for Step 4 becomes:
SubscriptionEntity(via the data-custodian integration mapping inV4_0_0__add_datacustodian_integration.sql) and stash its UUID into theOAuth2Authorizationattributes blob.OAuth2TokenIntrospectionAuthenticationProviderto read that attribute back and add it to the introspection response assubscription_id.Scope correction — Phase 2c is OUT OF SCOPE
Utilities do not allow third parties to POST, PUT, or DELETE metering or customer personal information data. Only GET request controllers need ESPI access enforcement in Phase 2.
Impact on the prior audit's API table:
@PostMapping/@PutMapping/@DeleteMappingon metering and customer-data controllers remain admin-only or are removed from the third-party path entirely (decision deferred to a follow-up issue if/when a write use case appears)Net effect on Step 4 effort
Slightly down from the revised 4–6 hr estimate — the introspection customization now surfaces one claim instead of three, and the resource-server side does one repository lookup instead of parsing JSON arrays. Call it 3–5 hr for Step 4. Scope-grammar alignment (Step 4.5, 3–4 hr) is unchanged. Phase 2c drop saves the ~6–8 hr that mutation-ownership-check work would have cost.
Grant-flow wiring audit to follow as a separate comment.
dfcoffin commentedon May 24, 2026
Step 1.5 — Grant→Subscription wiring audit
Follow-up to the Step 1 audit and its correction. Question: does the OAuth2 grant flow actually create a
SubscriptionEntityend-to-end today?Verdict: MISSING
Scaffolding is present (integration service, mapping table, stored proc) but no code path triggers it on grant completion.
SubscriptionService.createSubscription(...)has zero production callers in either module. The(retailCustomer, authorization, applicationInformation, usagePoints[])shape would never be populated by today's flow even if a subscription were created.Evidence
1.
SubscriptionService.createSubscriptionhas no callersopenespi-common/.../service/SubscriptionService.java:31openespi-common/.../service/impl/SubscriptionServiceImpl.java:65-104grep -r "\.createSubscription(" .→ zero matches in production code (both modules).Module visibility blocks the auth-server from invoking it directly anyway:
openespi-authserver/pom.xml:77— "No OpenESPI-Common dependency - Authorization Server is independent". Subscription creation must happen on the data-custodian side, via a remote call from auth-server.2.
DataCustodianIntegrationServicehas no inbound caller on the grant pathopenespi-authserver/.../service/DataCustodianIntegrationService.javaprovides the HTTP plumbing:createAuthorizationGrant(customerId, clientId, scope, usagePointIds)— lines 257-298, POSTs to{dataCustodian}/api/v1/auth/grantsstoreIntegrationMapping(authorizationId, grantId, ...)— lines 364-384, persists todatacustodian_integration_mappingWho invokes it: only
DataCustodianIntegrationController.java:198— an admin/manual REST endpoint at/api/v1/datacustodian/grants. No event listener, noOAuth2TokenCustomizer, no consent-accept hook calls it.ConsentController.processConsent()(lines 155-241) ends at line 232 with a redirect back to/oauth2/authorize. No integration-service call before the redirect.3.
SyncAuthorizationWithDataCustodianstored procedure is dead schemaopenespi-authserver/.../db/vendor/{mysql,postgresql}/V4_0_0__add_datacustodian_integration.sqldefines the proc.grep -r "SyncAuthorizationWithDataCustodian\|sync_authorization_with_datacustodian" .→ zero Java references. The migration runs but the proc is never called.4. Cross-module pattern: HTTP-callback scaffolding without the trigger
The intended pattern is clearly option (a) HTTP callback auth-server → data-custodian — that's what
DataCustodianIntegrationService.createAuthorizationGrant()is built for. What's missing is the moment-of-grant-completion hook that calls it:AuthorizationServerConfig.java:328-330— uses stockJdbcOAuth2AuthorizationService, no customizationEspiTokenCustomizer.customize()— JWT-only path (lines 65-90), short-circuits for opaque; even if it ran, it doesn't call the integration serviceApplicationListener<OAuth2AuthorizationSuccessEvent>or equivalent anywhere5.
AuthorizationEntitylink is undefined at runtimeSubscriptionEntity.java:96-98:AuthorizationEntityis data-custodian-local (openespi-common/.../domain/usage/AuthorizationEntity.java). It is not synced from the auth-server'soauth2_authorizationtable by any code I can find. If aSubscriptionEntitywere created today, itsauthorizationfield would be null.What's missing for Phase 2.0
Five gaps, in order of dependency:
ApplicationListenerfor the OAuth2 authorization success event (or a custom step in the token-granting filter), invokingDataCustodianIntegrationService.createAuthorizationGrant(...)with the consented customer + usage-point set.SubscriptionEntitywith(retailCustomer, authorization, applicationInformation, usagePoints[])populated, returns the newsubscription_id(UUID5).AuthorizationEntitymaterialization — data-custodian must own a row representing the OAuth2 authorization (mirror of auth-server'soauth2_authorization.id), soSubscriptionEntity.authorizationis non-null. Created in the same handler as Bump org.postgresql:postgresql from 42.7.4 to 42.7.7 in /openespi-datacustodian/OpenESPI-GreenButton-Workspace/OpenESPI-DataCustodian-java #2.subscription_idinto theOAuth2Authorization.attributesblob (andoauth2_authorization.datacustodian_grant_id), so the Step 4 introspection customizer can surface it as thesubscription_idclaim.SyncAuthorizationWithDataCustodianbecomes the fallback for async retry if the synchronous callback in Bump commons-io:commons-io from 2.4 to 2.14.0 in /openespi-datacustodian #1 fails.Effort impact on Phase 2.0
The earlier estimate assumed Step 4 was just "customize the introspection response." That step now requires the grant-completion plumbing first — gaps #1-#3 are prerequisites, because there's no
subscription_idto surface until subscriptions are actually being created.Revised:
SubscriptionService.createSubscription(...)wired +AuthorizationEntitymaterializationsubscription_id); simpler now that creation is solved upstreamRevised total: ~3–4 working days (vs. the prior 2–3 day estimate). The grant-completion plumbing is the surprise — without it, the entire Phase 2 enforcement model has nothing to enforce against.
Recommended sequencing for Phase 2.0 implementation
subscription_idexists to surface.dfcoffin commentedon May 25, 2026
Phase 2.0 Step 2 + 3 — Boot reality check vs. static audit (MAJOR update)
Follow-up to the Step 1 audit, correction, and grant-flow audit. Branch:
feature/issue-122-auth-server-bringup.Executive finding
The auth-server has not been booted clean against MySQL in living memory. Static audit findings were optimistic; runtime exposed seven independent pre-existing defects that must be repaired before opaque-token introspection can be verified end-to-end. Eighth defect identified but not yet patched.
The 3-4 day Phase 2.0 estimate from prior comments is invalid. Revised below.
Defects discovered during Step 2/3 boot attempts
oauth2_registered_client.client_idmissing UNIQUE constraint → FK fromespi_application_info.client_idfails on MySQLCREATE TABLEdb/vendor/mysql/V1_0_0__create_oauth2_schema.sql:67UNIQUE KEY uk_oauth2_registered_client_client_id (client_id)classpath:db/migration/{mysql,postgresql,h2}but source files live atdb/vendor/. Boot only "worked" historically because of staletarget/classes/db/migration/artifacts;mvn cleanexposes the gap.application.yml+docker-compose.ymlclasspath:db/vendor/...client_description,contact_name,contact_email,scope,grant_types,response_types) — ESPI 4.0 XSD-aligned schema repair needed. Tracked separately as #123.V3_0_0__add_default_data_and_test_clients.sqlspring.flyway.target: "2.0.0"inapplication-dev-mysql.yml. Full fix in #123.authorizationServerSecurityFilterChaindeclares both OIDC (which auto-wires JWT validation) and.opaqueToken(Customizer.withDefaults())on the same filter chain → Spring Security 7.x fails fast: "Spring Security only supports JWTs or Opaque Tokens, not both at the same time."AuthorizationServerConfig.java:140-144.opaqueToken(...); chain now uses.jwt(Customizer.withDefaults()). Outbound opaque tokens to ESPI clients unaffected (controlled per-RegisteredClientviaaccessTokenFormat(REFERENCE)).SecurityFilterChain @Order(0)beans (httpsEnforcementFilterChain,developmentSecurityFilterChain) withsecurityMatcher("/**")preempt the auth-server's@Order(1)chain, causing every OAuth2 endpoint to return 404. Header injection should not monopolize/**via a SecurityFilterChain.HttpsEnforcementConfig.java:101-176@Beanannotation commented out on both. Headers should be re-introduced viaHeaderWriterorFilterin a follow-up.auto-commit: falsepaired withJdbcRegisteredClientRepository.save()(no@Transactional) → seedINSERTstatements never commit and silently roll back on connection return. Boot log says "Inserted registered client: 1" three times; DB has 0 rows.application-dev-mysql.yml:17+JdbcRegisteredClientRepository.java:105auto-commit: truefor dev profile. Architectural fix (proper@Transactionalthroughout repository) deferred.authorizationServerSecurityFilterChainuses.oauth2AuthorizationServer(...)+.oauth2ResourceServer().jwt(...)+.anyRequest().authenticated()in one chain. Result: resource server bearer filter interceptsPOST /oauth2/tokenbefore the token-endpoint filter can handle basic auth → 401WWW-Authenticate: Bearer. Canonical pattern isOAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)with a SECOND@Order(2)chain for non-OAuth2 endpoints.AuthorizationServerConfig.java:119-173JdbcRegisteredClientRepository.findAll()returns empty list even when rows are persisted. Boot log: "Default ESPI Clients: 0" while DB SELECT returns 3 rows. Bug in the custom repository'sfindAllimplementation; not yet diagnosed.JdbcRegisteredClientRepository.java(line TBD)What "works" today on the branch
Boot reaches
Started AuthorizationServerApplication in ~35-42 seconds. Tomcat listens on 9999.GET /.well-known/oauth-authorization-server→ 200, advertises correct token + introspection URLsGET /.well-known/openid-configuration→ 200GET /oauth2/jwks→ 200GET /login→ 200What does NOT work today
POST /oauth2/tokenwith client_credentials → 401 (defect feat: upgrade to ESPI 4.0 schema and complete Spring Boot 3.5 migration fixes #7)/oauth2/introspect, cannot capture the actual introspection response shape (the original Step 3 objective)Revised Phase 2.0 estimate
Prior estimate (3-4 working days) invalidated. Realistic estimate:
JdbcRegisteredClientRepository.findAll()Recommendation for sequencing
Startedstate on dev-mysql) — discrete, reviewable, well-documented scope.Patches accumulated this session (today's PR scope)
Branch state
feature/issue-122-auth-server-bringup— 6 patches applied, no commits yet. About to be committed as a single PR titled along the lines of "fix(authserver): make dev-mysql boot reach Started state — six pre-existing defects". Patch #7 (filter chain) will land as a separate PR.fix(authserver): six pre-existing defects blocking dev-mysql boot (#125)
fix(authserver): sync profile defects discovered after PR #125 (#126)
fix(authserver): canonical Spring Security 7.x filter chain + Jackson…
fix(authserver): replace custom JdbcRegisteredClientRepository with S…
dfcoffin commentedon May 26, 2026
Reference: ESPI 4.0 Function Block (FB) scope terms
Authoritative source: NAESB ESPI 4.0 §REQ.21.4.2.1.3.1 ScopeFBTerms (
retail_bk21_121323 (Ver 4.0).pdf). Recording here as the canonical list the scope parser (Step 4.5) and the grant/candidate/granted-scope logic (Steps 3.5/3.6) must build from. ESPI scope grammar:FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13— theFB=list is a set of Function Block IDs.Energy / Usage domain
Customer / PII domain
Categorization the parser needs
ServiceCategory.kind): 05, 06, 07, 08, 09, 10, 11, 29Architectural consequence (from the design discussion on this issue)
Commodity is encoded in the FB set, but the
FB ↔ ServiceCategory ↔ UsagePointresolution stays in the data-custodian — the auth-server forwards the requested scope, renders DC-provided candidate UsagePoints (filtered by the commodity FBs), and uses the DC-computed granted (down-scoped) scope. All ESPI FB semantics have exactly one home (the DC). Enforcement (Phase 2) is two-part: requested resource ∈ the subscription's related UsagePoints AND requested data-shape ∈ granted-scope FBs.Example scopes: electric usage summary =
FB=4_5_15; gas =FB=4_10_15; both =FB=4_5_10_15; customer billing statement (PII) =FB=53_71.dfcoffin commentedon May 26, 2026
Architecture constraint + topology decision (Sandbox Redesign)
Settled during design discussion. These constraints govern the grant→subscription work (Steps 3.5/3.6) and the rest of Phase 2.
Hard constraints
SecurityConfiguration.java:190-195usesSpringOpaqueTokenIntrospector, no.jwt()/JwtDecoder/JWKSource.)Authorization flow (corrected)
The three URLs in the token response: Energy
Subscription, CustomerSubscription,Authorization(see the FB-terms reference comment — energy FBs 04–29, customer/PII FBs 53–71).Long-range topology (justifies the standalone AS — resolves the #112 question)
One common Authorization Server for Third-Party registration + opaque-token issuance/introspection, fronting multiple per-TP DataCustodian sandbox systems. Multiple sandbox deployments are anticipated. The AS's durable value is being the shared third-party registration + token surface across many DC sandboxes; each DC owns its own customer authentication (Spring Security login), Authorization Screen, Subscriptions, and resource serving/enforcement. → Keep the AS as a separate module (do not fold into the DC per the old #114 approach).
Open design point for the AS↔DC handoff: with multiple DCs behind one AS,
/authorizemust route the customer to the correct DC's login/authorization screen (e.g., per registered-client → DC mapping). To be detailed in the 3.5/3.6 design.Consequence for current code
SecurityConfiguration.java:219"Future enhancement: Add JWT support" comment.)JWKSource+JwtDecoder+ a.oauth2ResourceServer().jwt()self-protection (added in fix(authserver): canonical Spring Security 7.x filter chain + Jackson modules on custom repo #128) that the opaque-only ESPI flow never uses. To be stripped — tracked as a separate cleanup issue. Not breakage; unused machinery.dfcoffin commentedon May 26, 2026
Design — Grant→Subscription flow (Steps 3.5/3.6) under the corrected topology
Reference for the grant→subscription build. Builds on the constraint comment (opaque-only, Spring Security login, no JWK/JWT in the RS, OIDC-federation ruled out, common-AS/multi-DC) and the FB-terms reference.
Sections marked [PROPOSAL — review] are my recommended mechanism, not settled fact; everything else follows from already-agreed constraints.
1. Roles
openespi-authserver)openespi-datacustodian)/authorize,/token,/introspect,/revoke), opaque-token issuance + introspectionAuthorization+Subscriptioncreation, resource serving + enforcementThe AS is the shared third-party surface; each DC is a self-contained sandbox that authenticates its own customers and owns their data.
2. End-to-end flow
3. AS↔DC handoff mechanism [PROPOSAL — review]
No OIDC/JWT, so the handoff is a browser redirect + opaque decision-reference resolved over a back-channel:
Open question for review: should the decision reference be resolved via a new dedicated DC endpoint, or can it reuse/extend the existing introspection-style channel? Leaning new dedicated endpoint (different semantics from token introspection).
4. Multi-DC routing
/authorizemust send the customer to the right DC. Source of truth = the registered client → DC mapping (a TP is onboarded against a specific DC sandbox). Candidate: extend the registered-client metadata (orespi_application_info) with the target DC base URL; AS reads it during/authorize. [PROPOSAL — review]5. Auth-server self-endpoint protection — decision for #134
With OIDC + JWT removed, the auth-server's endpoints are secured as follows (this is the explicit decision #134 implements):
/oauth2/authorize,/oauth2/token,/oauth2/introspect,/oauth2/revokeclient_secret_basic) — unchanged, not a resource-server filter/userinfo,/connect/register(OIDC)/admin/oauth2/**,OAuthAdminController, registration UI)@Order(2)defaultSecurityFilterChain, which already doesformLogin) — operators log in@Order(2)chain; security via the one-time correlation handle, not a bearer tokenTherefore #134 = drop
.oidc(...), drop.oauth2ResourceServer().jwt(...), dropjwkSource()/jwtDecoder()/generateRsaKey(), confirm admin endpoints fall under the session-login chain. No opaque self-introspection needed — the AS does not protect any of its own endpoints with bearer tokens. Must be one coherent PR (never leave admin endpoints unprotected mid-change).6. Token response + introspection shape
/tokenresponse (Step 4 / a later PR): standard OAuth fields + ESPI extensionsgrantedScopeis thescopefield; plus the 3 resource URLs as custom members. Opaqueaccess_token,token_type=Bearer,expires_in.active,sub,aud,scope,iss,exp,iat,jti,client_id,token_type) plus a single custom claim referencing the subscription(s) so the DC can resolve them. The DC then walks the Subscription → related UsagePoints for enforcement.7. Enforcement (Phase 2, the DC — Step 4.5 + #13)
On each resource request: introspect →
{ active, grantedScope, subscription ref }, then enforce both:Step 4.5 replaces the
SCOPE_FB_15_READ_3rd_partyshorthand in the controllers'@PreAuthorizewith a parser over the realFB=…grammar. GET controllers only (utilities don't allow third-party writes).8. Re-derived PR breakdown (design → cleanup → build)
Authorization+Subscription(s), compute granted scope, mint 3 URLs, generate + resolve the opaque decision reference (back-channel endpoint)/authorizedelegation + multi-DC routing + callback + decision-reference resolution + auth-code issuance/tokenresponse augmentation (granted scope + 3 URLs)A+B are both DC-side and could merge into one "DC authorization ceremony" PR if kept reviewable. C is the AS-side counterpart. D/E are small AS token/introspection touches. F is Phase 2 proper.
9. Open design points (flagged for review before the relevant PR)
/authorizereads it (§4).SubscriptionEntityrows or one with a type discriminator (the entity is currently single — needs a decision when B is built).Feedback welcome on §3, §4, and the open points before PR A/B begin.
dfcoffin commentedon May 27, 2026
Design refinements (review feedback on the prior design comment)
Resolutions to the open points from the design comment:
§3 — AS↔DC handoff: governed by IETF OAuth, not a bespoke ESPI mechanism
The handoff is not an ESPI-standard concern — it relies on IETF OAuth 2.0 (RFC 6749) authorization-code semantics. Withdraw the "opaque decision-reference + back-channel resolve" proposal (§3 of the prior comment) — that was over-engineering. The TP↔AS exchange is the standard
authorization_codegrant; the AS↔DC step is a standard redirect-based Spring Security login delegation (the AS authenticates the resource owner against the DC, the DC renders the Authorization Screen, control returns to the AS to issue the code). No custom signed-assertion or bespoke reference protocol — use standard OAuth + Spring Security mechanisms.§4 — Multi-DC routing: deferred (future capability)
Not needed now. Current scope = one AS ↔ one configured DC. The registered-client→DC routing is a future capability (tied to the future OIDC work below) and is out of scope for the 3.5/3.6 build. Drop it from the PR breakdown for now.
§9 — Energy vs Customer subscriptions: two distinct rows, keyed by the Authorization URIs
Confirmed against the schema (
V1__Create_Base_Tables.sql):authorizations.resource_uri→ Energy Subscription URL;authorizations.customer_resource_uri→ Customer/PII Subscription URL (different namespaces, per the entity comments).subscriptions.authorization_id(nullable FK) already supports N subscriptions : 1 authorization, so energy + customer as twosubscriptionsrows is representable today.AuthorizationEntity.subscriptionis@OneToOne(singleauthorizations.subscription_id), which contradicts the two-subscription model. Decision for PR B: drive the two links offsubscriptions.authorization_id+ the two URI columns, and drop/deprecate the singleauthorizations.subscription_id@OneToOne. (Schema change ⇒ Flyway migration inopenespi-common.)Future (explicitly NOT now)
The AS will later support OIDC to let TPs register against separate utilities (the multi-DC/multi-utility registration capability). This is deferred and not to be designed at this time — but it's why #134 strips OIDC "for now," not "forever": OIDC returns when multi-utility registration is built. #134 should frame the removal as deferral, leaving the door open.
Net effect on the PR breakdown
dfcoffin commentedon May 27, 2026
Review — how a Subscription identifies the UsagePoints / Customer it represents
Reviewed
SubscriptionEntity+ thesubscriptions/subscription_usage_pointsschema. Input for PR B (subscription creation + schema rework).What's modeled today
retail_customer_idFK →RetailCustomerEntitysubscription_usage_pointsjoin table →usagePoints: List<UsagePointEntity>authorization_id(@OneToOne)application_information_idA subscription answers "what does it represent?" two ways:
retail_customer_id(belongsToCustomer(Long)).subscription_usage_points(includesUsagePoint(...),getUsagePointCount()). Granular per-meter binding: the customer picks which meters on the Authorization Screen, each becomes a join-table row.Atom
relatedlinks:SubscriptionEntitydeliberately does NOT extendIdentifiedObject(per its class doc) — no stored self/up links. Therel="related"UsagePoint links are generated dynamically by the DTO layer from theusagePointscollection. So the join table is the source of truth that the related links render from and that the resource server walks for enforcement.Gap — Customer/PII subscriptions are not modeled
There is no binding from a subscription to customer-domain resources (Customer, CustomerAccount, ServiceLocation, ServiceSupplier, Meter, EndDevice, Statement — the FB 53–71 set). The only join table is
subscription_usage_points; nothing equivalent exists for PII.SubscriptionEntityis UsagePoint-centric.For the dual-subscription design (Energy
authorizations.resource_uri+ Customerauthorizations.customer_resource_uri):retail_customer_id+ granted customer-FB scope (53–71); the DC resolves Customer/Account/ServiceLocation/etc. from the owning customer at access time, gated by the FB set. No new join table — PII consent is naturally all-or-nothing per the granted customer-FBs, unlike meter selection.subscription_customer_resourcesbinding only if granular per-PII-resource selection is ever required. Heavier; likely unnecessary.Enforcement shape that follows
subscription.usagePointsAND data-shape ∈ granted energy-FBs.subscription.retailCustomerAND resource-type ∈ granted customer-FBs.PR-B decisions consolidated
SubscriptionEntity.authorization@OneToOne→@ManyToOne(two subscriptions : one authorization, driven bysubscriptions.authorization_id); drop/deprecate the singleauthorizations.subscription_id.openespi-common.fix(authserver): strip unused OIDC/JWK/JWT — ESPI is opaque-only (#135)
dfcoffin commentedon May 28, 2026
AS ↔ DC Authorization-Flow Contract (Phase 2.0)
Pins the entry-UX decision and the full Authorization-Server ↔ Data-Custodian redirect contract so PRs B/C/D/F implement against a fixed interface. Supersedes nothing earlier in this issue; it makes the grant-flow mechanics concrete.
Pinned decision: Third-Party-initiated authorization code flow (Entry-UX "(a)")
The OAuth 2.1 authorization code flow is always initiated by the Third Party (the OAuth client). The DC Authorization Screen is a mid-flow interstitial, never the protocol origin.
NAESB ESPI requirement (normative): the flow must be initiated by the TP. A customer on a utility's portal may navigate to a TP website, but that navigation is a bare hyperlink only — no OAuth information is transferred (no
client_id,redirect_uri,scope,state, and noinitiate_login_uri/IdP-initiated parameters). The TP then begins a fresh, standard/authorizerequest on its own. Consequently there is no utility-portal-initiated protocol variant; "Connect My Data" from the portal is just navigation followed by a normal TP-initiated flow.Why this is the right call for the sandbox:
The redirect chain (TP is the origin throughout)
Delegation mechanics
Spring Authorization Server supplies the redirect hooks; the cross-application orchestration is our customization (Spring ships in-app pages, not "redirect to my other app").
HttpSecurity.formLogin().loginPage("<DC-login-URL>"). AS 302s to the DC login when the principal is unauthenticated. The DC owns the only credential UI (utility customer portal); the AS has no login form.OAuth2AuthorizationServerConfigurer.authorizationEndpoint().consentPage("<DC-authz-screen-URL>"). AS 302s to the DC Authorization Screen when consent is required, passing the standard params (scope,client_id,state, …). The DC renders candidate UsagePoints + PII consent and form-POSTs the granted scope back to the AS/oauth2/authorize, which Spring AS treats as the consent decision and binds to the authorization code.Authenticationthe AS sees when issuing the code. Achieved with a shared Spring Session (e.g. Redis-backed) across AS and DC, or same-origin cookie scoping in single-host dev. No OIDC, no JWT — just a sharedHttpSession.Scope handling
granted_scope ⊆ requested_scope. The DC may drop FBs the customer declined but cannot escalate. The token's granted scope is the DC-decided set.FB=…. It shuttles the requested scope to the DC and echoes the DC-granted scope into the token. All ESPI FB semantics live solely on the DC (catalog/parser inopenespi-common, consumed by the DC).openespi-authserverkeeps no dependency onopenespi-commonand noFunctionBlockreference — eliminating AS/DC interpretation drift by construction.Out-of-band resource binding (the URIs that aren't scope)
Standard OAuth carries only
scope. The customer-selected UsagePoint URIs and customer URI are resource bindings, not scope, and travel out-of-band:{ correlation_id (= AS state), granted_scope, selected_usagepoint_uris, customer_uri }at consent time./oauth2/token, before minting, the AS calls the DC over the IETF-OAuth back-channel (PR B subscription-create) with{ correlation_id, client_id, granted_scope }.Subscription+Authorizationrecords and returns{ resource_uri, authorization_uri, customer_resource_uri }.EspiTokenCustomizer's logic migrates to on the opaque path (PR D).Token format
Opaque (
OAuth2TokenFormat.REFERENCE) only, per ESPI 4.0. Validation via RFC 7662 introspection. No JWT/JWK anywhere in AS or DC (settled in #134/#135).Component → PR mapping
EspiScopeparser +FunctionBlock/FunctionBlockCategorycatalog inopenespi-common, unit-testedSubscriptionEntity.authorization@OneToOne→@ManyToOne; customer-subscription derive-from-scopeconsentPage/loginPagedelegation config + DC login & Authorization Screen (candidate resolution); shared-session wiringEspiTokenCustomizerURI logic)@PreAuthorizewiring (resource ∈ subscription UsagePoints AND data-shape ∈ granted FBs); replacesSCOPE_FB_15_READ_3rd_partyshorthandOut of scope (deferred)
feat(common): ESPI FB-scope parser + Function Block catalog (#122 PR …
refactor(common): subscription↔authorization N:1, authorization as ag…
feat(#122 PR B2): AS↔DC subscription-provisioning back-channel (#139)
feat(#122 PR C1): signed-handoff codec + nonce table foundation (#140)
feat(#122 PR C2a): DC customer/admin login plumbing (#142)
feat(#122 PR C2b): customer Authorization Screen with per-FB consent (#…
feat(#122 PR C3): AS-side delegation to DC + /oauth2/authorize/contin…
feat(#122 PR C4): AS-side back-channel call + token-response augmenta…
feat(#148): Phase 2.0 auth-code-flow orchestration integration test (c…