Skip to content

Phase 2.0 — Stand up auth-server in dev with opaque tokens + subscription-bound introspection (Phase 2 precursor) #122

Closed

Description

@dfcoffin

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; confirm tokenSettings() format (REFERENCE vs SELF_CONTAINED)
  • Confirm /oauth2/introspect is enabled and properly authenticated (client credentials, not bearer)
  • Find any OpaqueTokenIntrospector / OAuth2TokenCustomizer beans
  • Inspect OAuth2Authorization persistence; locate where subscription binding could be stored at grant time
  • Verify oauth2_authorization table 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-mysql profile)
  • Boot openespi-authserver with dev-mysql; resolve any Flyway migration drift
  • Verify /oauth2/.well-known/oauth-authorization-server returns 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 RegisteredClient to OAuth2TokenFormat.REFERENCE and re-test

Step 4 — Add subscription_id to introspection response (the wildcard)

  • Try Approach B first: customize auth-server OAuth2TokenIntrospectionAuthenticationProvider / converter to add subscription_id to the introspection claims
  • If Spring Authorization Server doesn't expose a clean hook, fall back to Approach A: custom resource-server OpaqueTokenIntrospector that 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-server as a RegisteredClient with client_credentials grant + 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 = JSON declarations 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

  1. added
    ESPI 4.0Touches the NAESB ESPI 4.0 implementation
    infrastructureCI, build, deployment, or developer tooling
    blockingBlocks other work or CI
    on May 22, 2026
  2. dfcoffin commented on May 22, 2026

    @dfcoffin
    ContributorAuthor

    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)

    • Token format = opaque (OAuth2TokenFormat.REFERENCE).
      AuthorizationServerConfig.java:254-300 — all three default clients
      (data_custodian_admin, third_party, third_party_admin) set
      accessTokenFormat(OAuth2TokenFormat.REFERENCE). Default profile
      (application.yml:96) also sets espi.token.format: opaque. No JWT
      configuration anywhere on the access-token path.
    • Introspection endpoint enabled.
      AuthorizationServerConfig.java:378 — AuthorizationServerSettings declares
      .tokenIntrospectionEndpoint("/oauth2/introspect").
    • Data-custodian is already wired as an opaque-token resource server.
      openespi-datacustodian/.../config/SecurityConfiguration.java:188-228 builds
      the SecurityFilterChain with .opaqueToken(opaque -> opaque.introspectionUri(...).introspectionClientCredentials(...))
      and exposes an OpaqueTokenIntrospector bean using
      SpringOpaqueTokenIntrospector.withIntrospectionUri(...).
      application.yml:86-94 provides the matching config keys.
    • Authorization persistence = JdbcOAuth2AuthorizationService.
      AuthorizationServerConfig.java:328-330. Writes to oauth2_authorization
      (schema in V1_0_0__create_oauth2_schema.sql).
    • Auth-server scopes already use the spec-compliant ESPI 4.0 grammar.
      AuthorizationServerConfig.java:276-277 registers
      "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13" and
      the 900-second variant as RegisteredClient scopes.

    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 @PreAuthorize lines (in every controller) expect
    SCOPE_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 the
    authentication 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 403 today once
    introspection 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.sql added an integration model that
    binds OAuth2 authorizations to data-custodian context:

    • oauth2_authorization.datacustodian_customer_id — retail customer
    • oauth2_authorization.datacustodian_grant_id — grant ID
    • datacustodian_integration_mapping table — links authorization_id →
      customer_id + usage_point_ids (JSON array of UUIDs)

    There is no subscription_id column 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), not subscriptionId. The
    enforcePathMatch step rewrites: pathSubscriptionId must resolve (via
    data-custodian's own SubscriptionEntity or grant lookup) to a (customerId, usagePointIds) tuple that matches the token's binding. The original Phase 2
    design 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/introspect response from Spring Authorization Server
    carries active, sub, scope, client_id, exp, iat, token_type,
    username, nbf, jti (RFC 7662). It does not carry
    datacustodian_customer_id or any ESPI-specific binding.

    No OAuth2TokenIntrospectionAuthenticationProvider customization exists. No
    custom IntrospectionResponse extension exists. The
    EspiTokenCustomizer.java bean (line 54+) is OAuth2TokenCustomizer<JwtEncodingContext>
    — JWT-only, and it short-circuits unless espi.token.format=jwt. It is
    inert 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:

    • Approach A (resource-server enrichment) — data-custodian's
      OpaqueTokenIntrospector wraps the default, reads principal_name from the
      introspection response, then queries oauth2_authorization (cross-schema or
      via auth-server API) to look up datacustodian_customer_id and the mapped
      usage_point_ids. Couples data-custodian to auth-server's database/API.
    • Approach B (auth-server response enrichment) — auth-server registers a
      custom OAuth2TokenIntrospectionAuthenticationProvider (or a bean that wraps
      it) that pulls datacustodian_customer_id from the stored
      OAuth2Authorization attributes 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 (OAuth2TokenIntrospectionEndpointConfigurer
    or 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

    • JWK source still generated. AuthorizationServerConfig.java:344-355
      generates 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).
    • Default security filter chain at order(2) is commented out.
      AuthorizationServerConfig.java:184-229 — entire defaultSecurityFilterChain
      bean is // @Bean // @Order(2) and the body is commented inline. The
      authorization-server filter chain at order(1) handles everything. Not a
      bug, but suggests the file needs a cleanup pass.
    • JdbcOAuth2AuthorizationService writes the full OAuth2Authorization —
      including attributes (blob) and access_token_metadata (blob) — but
      custom 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

    Step Status Notes
    1 — Config audit DONE (this comment) –
    2 — Local dev boot ~1–2 hr Standard; expect Flyway drift
    3 — Verify opaque tokens ~30 min Should already pass given §1 findings
    4 — Introspection enrichment ~4–6 hr (up from 2–4) Approach B requires customizing OAuth2TokenIntrospectionAuthenticationProvider; needs to surface datacustodian_customer_id + usage_point_ids
    5 — Wire data-custodian MOSTLY DONE Already configured; only need to swap OpaqueTokenIntrospector bean for one that recognizes the enriched response shape and builds an ESPI principal
    6 — End-to-end smoke test ~2–3 hr Now must also exercise the scope-grammar alignment
    NEW Step 4.5 — Scope-grammar alignment ~3–4 hr Replace SCOPE_FB_15_* shorthand in every controller's @PreAuthorize with 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

    1. Update issue Phase 2.0 — Stand up auth-server in dev with opaque tokens + subscription-bound introspection (Phase 2 precursor) #122 description with the scope-grammar finding as a new
      acceptance criterion (alignment must work end-to-end in the smoke test).
    2. Decide between Approach A and Approach B before any code is written.
    3. Boot the auth-server locally (Step 2) and exercise the introspection
      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.
  3. dfcoffin commented on May 24, 2026

    @dfcoffin
    ContributorAuthor

    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_id column anywhere in the auth-server schema… Phase 2 design implication: the principal carries (customerId, usagePointIds[], scopes, isAdmin), not subscriptionId."

    Reality: I was looking in the wrong place. SubscriptionEntity already exists as the canonical model — owned by data-custodian (via openespi-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> usagePoints via subscription_usage_points join table — authorized FB resources
      • Helpers: isActive(), isExpired(), isRevoked(), includesUsagePoint(...), belongsToCustomer(...)

    The auth-server schema's lack of a subscription_id column is not a gap — auth-server doesn't own subscriptions. SubscriptionEntity is 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 distinct SubscriptionEntity.

    Revised principal model

    Before After
    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:

    1. Path-bound endpoints (/Subscription/{subId}/UsagePoint/...): assert pathSubId.equals(principal.subscriptionId) — single string compare.
    2. Root collection / by-id endpoints: filter/lookup must be scoped to 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:

    {
      "active": true,
      "sub": "...",
      "scope": "FB=4_5_15;IntervalDuration=3600;...",
      "client_id": "third_party",
      "exp": 1234567890,
      "subscription_id": "urn:uuid:xxxxxxxx-xxxx-5xxx-xxxx-xxxxxxxxxxxx"   <-- single custom claim
    }
    

    One UUID. Data-custodian's OpaqueTokenIntrospector wrapper reads subscription_id and 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:

    • At grant time (consent acceptance), look up or create the SubscriptionEntity (via the data-custodian integration mapping in V4_0_0__add_datacustodian_integration.sql) and stash its UUID into the OAuth2Authorization attributes blob.
    • Customize OAuth2TokenIntrospectionAuthenticationProvider to read that attribute back and add it to the introspection response as subscription_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:

    • Rows M1–M9 (mutation endpoints) → drop, no enforcement work in this phase
    • All @PostMapping / @PutMapping / @DeleteMapping on 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.

  4. dfcoffin commented on May 24, 2026

    @dfcoffin
    ContributorAuthor

    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 SubscriptionEntity end-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.createSubscription has no callers

    • Interface: openespi-common/.../service/SubscriptionService.java:31
    • Impl: openespi-common/.../service/impl/SubscriptionServiceImpl.java:65-104
    • grep -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. DataCustodianIntegrationService has no inbound caller on the grant path

    openespi-authserver/.../service/DataCustodianIntegrationService.java provides the HTTP plumbing:

    • createAuthorizationGrant(customerId, clientId, scope, usagePointIds) — lines 257-298, POSTs to {dataCustodian}/api/v1/auth/grants
    • storeIntegrationMapping(authorizationId, grantId, ...) — lines 364-384, persists to datacustodian_integration_mapping

    Who invokes it: only DataCustodianIntegrationController.java:198 — an admin/manual REST endpoint at /api/v1/datacustodian/grants. No event listener, no OAuth2TokenCustomizer, 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. SyncAuthorizationWithDataCustodian stored procedure is dead schema

    openespi-authserver/.../db/vendor/{mysql,postgresql}/V4_0_0__add_datacustodian_integration.sql defines 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 stock JdbcOAuth2AuthorizationService, no customization
    • EspiTokenCustomizer.customize() — JWT-only path (lines 65-90), short-circuits for opaque; even if it ran, it doesn't call the integration service
    • No ApplicationListener<OAuth2AuthorizationSuccessEvent> or equivalent anywhere

    5. AuthorizationEntity link is undefined at runtime

    SubscriptionEntity.java:96-98:

    @OneToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
    @JoinColumn(name = "authorization_id")
    private AuthorizationEntity authorization;

    AuthorizationEntity is data-custodian-local (openespi-common/.../domain/usage/AuthorizationEntity.java). It is not synced from the auth-server's oauth2_authorization table by any code I can find. If a SubscriptionEntity were created today, its authorization field would be null.

    What's missing for Phase 2.0

    Five gaps, in order of dependency:

    1. Grant-completion hook on auth-server — ApplicationListener for the OAuth2 authorization success event (or a custom step in the token-granting filter), invoking DataCustodianIntegrationService.createAuthorizationGrant(...) with the consented customer + usage-point set.
    2. Subscription-create endpoint on data-custodian — receives the auth-server callback, creates SubscriptionEntity with (retailCustomer, authorization, applicationInformation, usagePoints[]) populated, returns the new subscription_id (UUID5).
    3. AuthorizationEntity materialization — data-custodian must own a row representing the OAuth2 authorization (mirror of auth-server's oauth2_authorization.id), so SubscriptionEntity.authorization is 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.
    4. Subscription-id round-trip into the OAuth2Authorization attributes — auth-server stashes the returned subscription_id into the OAuth2Authorization.attributes blob (and oauth2_authorization.datacustodian_grant_id), so the Step 4 introspection customizer can surface it as the subscription_id claim.
    5. Activate the stored proc (optional) — SyncAuthorizationWithDataCustodian becomes 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_id to surface until subscriptions are actually being created.

    Revised:

    Step Prior estimate New estimate Notes
    1 — Config audit DONE DONE –
    1.5 — Grant-flow audit – DONE (this comment) –
    2 — Local dev boot 1–2 hr 1–2 hr unchanged
    3 — Verify opaque tokens 30 min 30 min unchanged
    3.5 — Grant-completion hook (NEW) – 4–6 hr Listener + integration-service call + error handling
    3.6 — Subscription-create endpoint (NEW) – 4–6 hr Data-custodian REST endpoint + SubscriptionService.createSubscription(...) wired + AuthorizationEntity materialization
    4 — Introspection enrichment 3–5 hr 2–3 hr One claim (subscription_id); simpler now that creation is solved upstream
    4.5 — Scope-grammar alignment 3–4 hr 3–4 hr unchanged
    5 — Wire data-custodian mostly done mostly done unchanged
    6 — End-to-end smoke test 2–3 hr 3–4 hr Now covers full grant→subscription→introspection→GET cycle

    Revised 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

    1. Step 2 (boot) and Step 3 (verify opaque) first — cheap, confirms the audit's static findings against runtime reality.
    2. Step 3.5 + 3.6 together — they're two halves of one round-trip and can't be tested independently.
    3. Step 4 once a real subscription_id exists to surface.
    4. Step 4.5 in parallel with Step 4 — independent work, different files.
    5. Step 6 last — exercises everything.
  5. dfcoffin commented on May 25, 2026

    @dfcoffin
    ContributorAuthor

    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

    # Defect File Severity Patched on branch
    1 oauth2_registered_client.client_id missing UNIQUE constraint → FK from espi_application_info.client_id fails on MySQL CREATE TABLE db/vendor/mysql/V1_0_0__create_oauth2_schema.sql:67 Blocker ✅ Added UNIQUE KEY uk_oauth2_registered_client_client_id (client_id)
    2 Flyway YAML configs point at classpath:db/migration/{mysql,postgresql,h2} but source files live at db/vendor/. Boot only "worked" historically because of stale target/classes/db/migration/ artifacts; mvn clean exposes the gap. 4 profile YAMLs + base application.yml + docker-compose.yml Blocker ✅ All 6 config files now reference classpath:db/vendor/...
    3 V3+ migrations reference columns that don't exist in MySQL V1 schema (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.sql Blocker for V3+ ✅ Workaround: spring.flyway.target: "2.0.0" in application-dev-mysql.yml. Full fix in #123.
    4 authorizationServerSecurityFilterChain declares 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 Blocker ✅ Removed .opaqueToken(...); chain now uses .jwt(Customizer.withDefaults()). Outbound opaque tokens to ESPI clients unaffected (controlled per-RegisteredClient via accessTokenFormat(REFERENCE)).
    5 Two SecurityFilterChain @Order(0) beans (httpsEnforcementFilterChain, developmentSecurityFilterChain) with securityMatcher("/**") 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 Blocker ✅ @Bean annotation commented out on both. Headers should be re-introduced via HeaderWriter or Filter in a follow-up.
    6 HikariCP auto-commit: false paired with JdbcRegisteredClientRepository.save() (no @Transactional) → seed INSERT statements 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:105 Blocker ✅ Set auto-commit: true for dev profile. Architectural fix (proper @Transactional throughout repository) deferred.
    7 authorizationServerSecurityFilterChain uses .oauth2AuthorizationServer(...) + .oauth2ResourceServer().jwt(...) + .anyRequest().authenticated() in one chain. Result: resource server bearer filter intercepts POST /oauth2/token before the token-endpoint filter can handle basic auth → 401 WWW-Authenticate: Bearer. Canonical pattern is OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) with a SECOND @Order(2) chain for non-OAuth2 endpoints. AuthorizationServerConfig.java:119-173 Blocker ❌ Not yet patched. Filed as separate issue.
    8 JdbcRegisteredClientRepository.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's findAll implementation; not yet diagnosed. JdbcRegisteredClientRepository.java (line TBD) Suspected blocker for admin endpoints ❌ Not yet investigated.

    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 URLs
    • GET /.well-known/openid-configuration → 200
    • GET /oauth2/jwks → 200
    • GET /login → 200

    What does NOT work today

    Revised Phase 2.0 estimate

    Prior estimate (3-4 working days) invalidated. Realistic estimate:

    Work Effort
    Patches #1-#6 → production-quality PR with tests 1-2 days (today's patches are demo-quality; need test coverage and code review)
    Patch #7 — filter chain canonical pattern + second chain 0.5-1 day
    Patch #8 — diagnose and fix custom JdbcRegisteredClientRepository.findAll() 0.5 day (and likely surfaces more bugs in the custom repo — full audit recommended)
    #123 — ESPI 4.0 XSD-aligned schema repair (MySQL/PostgreSQL/H2) 0.5-1 day
    V4-V6 migration audit (likely more drift) 0.5 day (audit only; fixes additional)
    Spring Authorization Server canonical config audit (find remaining non-canonical patterns) 0.5 day
    Original Step 3.5 (grant-completion hook) + 3.6 (subscription-create endpoint) 1-1.5 days (unchanged from prior estimate)
    Step 4 (introspection enrichment) + Step 4.5 (scope-grammar alignment) 0.5-1 day combined
    Step 6 (end-to-end smoke test) 0.5 day
    Total 6-10 working days

    Recommendation for sequencing

    1. Land today's 6-patch PR (boot reaches Started state on dev-mysql) — discrete, reviewable, well-documented scope.
    2. Land patch feat: upgrade to ESPI 4.0 schema and complete Spring Boot 3.5 migration fixes #7 + refactor: standardize repository naming convention #8 as a second PR — restore actual token issuance + repository correctness.
    3. ApplicationInformation schema drift from ESPI 4.0 XSD across MySQL/PostgreSQL/H2 V1 migrations #123 schema repair in parallel — different files, independent reviewers possible.
    4. Then proceed with the original Phase 2.0 steps 3.5 onwards (grant-completion hook, subscription endpoint, introspection enrichment).

    Patches accumulated this session (today's PR scope)

    9 files changed, 32 insertions(+), 15 deletions(-)
     openespi-authserver/docker/docker-compose.yml
     .../authserver/config/AuthorizationServerConfig.java        (patch #4)
     .../authserver/config/HttpsEnforcementConfig.java           (patch #5)
     .../resources/application-dev-mysql.yml                     (patches #2, #3, #6)
     .../resources/application-dev-postgresql.yml                (patch #2)
     .../resources/application-local.yml                         (patch #2)
     .../resources/application-prod.yml                          (patch #2)
     .../resources/application.yml                               (patch #2)
     .../db/vendor/mysql/V1_0_0__create_oauth2_schema.sql        (patch #1)
    

    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.

  6. dfcoffin commented on May 26, 2026

    @dfcoffin
    ContributorAuthor

    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 — the FB= list is a set of Function Block IDs.

    Energy / Usage domain

    FB Meaning Category
    04 UsagePoint base (required for any usage-point data)
    05 Electricity commodity
    06 Demand Electricity commodity
    07 Electricity Net Meter commodity
    08 Forward and Reverse Metering commodity
    09 Register Values commodity
    10 Natural Gas commodity
    11 Water commodity
    12 Cost of Interval Data (any UsagePoint Interval) data-shape
    15 Usage Summary data-shape
    16 Usage Summary with Cost data-shape
    17 PowerQuality Summary data-shape
    27 Usage Summary with Demands and Previous Day Attributes data-shape
    28 Usage Summary Costs for Current Billing Period data-shape
    29 Temperature Interval Metering commodity (electricity-adjacent)

    Customer / PII domain

    FB Meaning
    53 Customer (base for customer/PII)
    54 Basic Customer Information
    55 Customer Address Information
    56 Customer Account
    57 Customer Agreement
    58 Service Location
    59 Service Supplier
    60 Meter Information
    61 EndDevice Information
    62 ProgramDateIdMapping Information
    71 Customer Billing Statement

    Categorization the parser needs

    • Base: 04 (UsagePoint), 53 (Customer)
    • Commodity (selects UsagePoints by ServiceCategory.kind): 05, 06, 07, 08, 09, 10, 11, 29
    • Energy data-shape: 12, 15, 16, 17, 27, 28
    • Customer/PII resources: 54–62, 71

    Architectural consequence (from the design discussion on this issue)

    Commodity is encoded in the FB set, but the FB ↔ ServiceCategory ↔ UsagePoint resolution 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.

  7. dfcoffin commented on May 26, 2026

    @dfcoffin
    ContributorAuthor

    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

    1. ESPI 4.0 supports opaque access tokens only. No JWT in the access-token path, anywhere.
    2. The GBA Resource Server (data-custodian) must contain NO JWK/JWT functions — opaque-token introspection only. (Already true today: SecurityConfiguration.java:190-195 uses SpringOpaqueTokenIntrospector, no .jwt()/JwtDecoder/JWKSource.)
    3. Utilities authenticate customers via Spring Security login (form/session), NOT as OIDC providers. Therefore utility-as-IdP via OIDC federation is RULED OUT — OIDC requires signed JWT id_tokens, which conflicts with (1) and (2).

    Authorization flow (corrected)

    Third Party  ──▶  AS /authorize
                         │  (delegates auth + consent to the utility, via session/redirect — NOT OIDC)
                         ▼
                  Utility / DC: Spring Security login  +  Authorization Screen
                         │   (customer sees their UsagePoints + PII per requested FB scope; accept/deny)
                         ▼  accept  →  DC creates Authorization + Subscription(s) locally, computes granted scope
                  AS issues opaque token (granted scope + 3 resource URLs)
                         ▼
       access:    Third Party ──▶ DC ──▶ DC introspects against AS (active + granted scope + subscription ref)
                                       └▶ DC enforces: resource ∈ subscription UsagePoints AND data-shape ∈ granted-scope FBs
    

    The three URLs in the token response: Energy Subscription, Customer Subscription, 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, /authorize must 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

    • Data-custodian: no functional change — already opaque-only. (Remove only the misleading SecurityConfiguration.java:219 "Future enhancement: Add JWT support" comment.)
    • Auth-server: carries OIDC + 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.
    • Design: OIDC-federation topology for 3.5/3.6 is abandoned in favor of Spring Security login + session/redirect handoff.
  8. dfcoffin commented on May 26, 2026

    @dfcoffin
    ContributorAuthor

    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

    Common Authorization Server (openespi-authserver) DataCustodian sandbox(es) (openespi-datacustodian)
    Owns TP registration, OAuth protocol endpoints (/authorize, /token, /introspect, /revoke), opaque-token issuance + introspection Customer authentication (Spring Security login), Authorization Screen, Authorization + Subscription creation, resource serving + enforcement
    Knows about customers nothing (delegates) everything (its own retail customers + their UsagePoints/PII)
    Token type opaque (REFERENCE) validates via introspection only
    Count one (shared) many (one per TP sandbox / per deployment)

    The 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

    1. TP ──▶ AS /authorize?client_id&scope=FB=…&redirect_uri&state
    2. AS resolves target DC from the registered-client→DC mapping (multi-DC routing)
    3. AS (browser redirect) ──▶ DC authorization-screen URL  [auth request context + return URL]
    4. DC: Spring Security form login (customer's utility credentials)
    5. DC: Authorization Screen — renders the customer's UsagePoints (commodity-filtered by the
           requested FB set) + PII resources; customer selects + accepts/denies
    6. on ACCEPT — DC LOCALLY:
           • creates AuthorizationEntity (granted scope, status, expiry)
           • creates Subscription(s): Energy (FB 04–29) and/or Customer/PII (FB 53–71)
           • computes the granted (down-scoped) scope
           • mints the 3 resource URLs (energySubUrl, customerSubUrl, authorizationUrl)
           • generates an opaque, one-time, short-lived DECISION REFERENCE
    7. DC (browser redirect) ──▶ AS callback  [decision reference + correlation + accept/deny]
    8. AS ──▶ DC back-channel "resolve decision reference" (client-credential auth, server-to-server)
           ◀── { subject, grantedScope, energySubUrl, customerSubUrl, authorizationUrl, decision }
    9. AS issues its OAuth authorization code to the TP (redirect to TP redirect_uri + state)
    10. TP ──▶ AS /token (code exchange) ──▶ opaque access token + { granted scope, 3 URLs } in response
    11. access: TP ──▶ DC (Bearer opaque) ──▶ DC introspects against AS ──▶ DC enforces
    

    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:

    • Sensitive data (customer subject, granted scope, resource URLs) never rides through the browser — only an opaque one-time reference does (steps 7→8). This mirrors the opaque-token + introspection philosophy: hand out a reference, resolve it server-to-server.
    • The back-channel "resolve decision" endpoint on the DC is client-credential authenticated (same trust as introspection), one-time-use, short TTL, bound to the correlation handle from step 3 to prevent replay/injection.
    • Alternative considered and rejected: passing a signed assertion through the browser — rejected because signing implies JWT/JWK, which the constraints forbid.

    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

    /authorize must 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 (or espi_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):

    Endpoint group Protection after #134
    /oauth2/authorize, /oauth2/token, /oauth2/introspect, /oauth2/revoke The authorization-server configurer's built-in client authentication (client_secret_basic) — unchanged, not a resource-server filter
    /userinfo, /connect/register (OIDC) Removed — OIDC is not used in the opaque ESPI flow
    Admin / client-management (/admin/oauth2/**, OAuthAdminController, registration UI) Spring Security session/form login (the @Order(2) defaultSecurityFilterChain, which already does formLogin) — operators log in
    The AS callback (step 7) A normal MVC endpoint on the @Order(2) chain; security via the one-time correlation handle, not a bearer token

    Therefore #134 = drop .oidc(...), drop .oauth2ResourceServer().jwt(...), drop jwkSource()/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

    • /token response (Step 4 / a later PR): standard OAuth fields + ESPI extensions grantedScope is the scope field; plus the 3 resource URLs as custom members. Opaque access_token, token_type=Bearer, expires_in.
    • Introspection response (Step 4 enrichment): RFC 7662 base (verified working — 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:

    1. requested resource ∈ the subscription's related UsagePoints (ownership/commodity), and
    2. requested data-shape ∈ the granted-scope FB function-blocks (per the FB-terms reference).

    Step 4.5 replaces the SCOPE_FB_15_READ_3rd_party shorthand in the controllers' @PreAuthorize with a parser over the real FB=… grammar. GET controllers only (utilities don't allow third-party writes).

    8. Re-derived PR breakdown (design → cleanup → build)

    PR Scope Depends on
    #134 Cleanup: strip OIDC/JWK/JWT, re-secure self-endpoints per §5 — (prerequisite)
    A DC: customer Spring Security login + Authorization Screen (UsagePoint commodity-filtered + PII selection, accept/deny); DC reads its own data to render candidates #134
    B DC: accept-handling — create Authorization + Subscription(s), compute granted scope, mint 3 URLs, generate + resolve the opaque decision reference (back-channel endpoint) A
    C AS: /authorize delegation + multi-DC routing + callback + decision-reference resolution + auth-code issuance B
    D AS: /token response augmentation (granted scope + 3 URLs) C
    E AS introspection enrichment (subscription ref) D
    F DC enforcement: FB-grammar scope parser + Subscription/related-UsagePoint checks on GET controllers (Step 4.5 + Phase 2 / #13) E

    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)

    1. Decision-reference resolution: new dedicated DC endpoint vs. extend introspection (§3).
    2. Registered-client → DC mapping storage + how /authorize reads it (§4).
    3. Whether Energy and Customer subscriptions are two SubscriptionEntity rows or one with a type discriminator (the entity is currently single — needs a decision when B is built).
    4. Customer identity correlation across AS↔DC (the AS holds no customer identity; the subject in introspection is DC-scoped).

    Feedback welcome on §3, §4, and the open points before PR A/B begin.

  9. dfcoffin commented on May 27, 2026

    @dfcoffin
    ContributorAuthor

    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_code grant; 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 two subscriptions rows is representable today.
    • Modeling tension to resolve in PR B: AuthorizationEntity.subscription is @OneToOne (single authorizations.subscription_id), which contradicts the two-subscription model. Decision for PR B: drive the two links off subscriptions.authorization_id + the two URI columns, and drop/deprecate the single authorizations.subscription_id @OneToOne. (Schema change ⇒ Flyway migration in openespi-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

    • §4 routing work removed (single DC).
    • §3 simplifies — PR C (AS delegation) uses standard OAuth code flow + Spring Security login delegation, no bespoke handoff protocol.
    • PR B gains the subscription/authorization schema decision (two-subscription model + Flyway migration).
    • Sequence unchanged: Strip unused OIDC/JWK/JWT from auth-server (ESPI is opaque-only) #134 → A → B → C → D → E → F.
  10. dfcoffin commented on May 27, 2026

    @dfcoffin
    ContributorAuthor

    Review — how a Subscription identifies the UsagePoints / Customer it represents

    Reviewed SubscriptionEntity + the subscriptions / subscription_usage_points schema. Input for PR B (subscription creation + schema rework).

    What's modeled today

    Binding Mechanism Cardinality
    Owning customer retail_customer_id FK → RetailCustomerEntity one customer per subscription
    UsagePoints (energy) subscription_usage_points join table → usagePoints: List<UsagePointEntity> many, explicitly enumerated
    Authorization authorization_id (@OneToOne) one
    Third party application_information_id one

    A subscription answers "what does it represent?" two ways:

    1. Which customer — the single retail_customer_id (belongsToCustomer(Long)).
    2. Which UsagePoints — the enumerated set in 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 related links: SubscriptionEntity deliberately does NOT extend IdentifiedObject (per its class doc) — no stored self/up links. The rel="related" UsagePoint links are generated dynamically by the DTO layer from the usagePoints collection. 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. SubscriptionEntity is UsagePoint-centric.

    For the dual-subscription design (Energy authorizations.resource_uri + Customer authorizations.customer_resource_uri):

    • Energy Subscription — fully expressible today (enumerated UsagePoints + owning customer). ✓
    • Customer Subscription — nothing enumerates its granted PII resources. Decision for PR B:
      • (a) Derive, don't enumerate (recommended): grants the customer's PII resources implied by 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.
      • (b) Enumerate: add a parallel subscription_customer_resources binding only if granular per-PII-resource selection is ever required. Heavier; likely unnecessary.

    Enforcement shape that follows

    • Energy: requested UsagePoint ∈ subscription.usagePoints AND data-shape ∈ granted energy-FBs.
    • Customer/PII (option a): requested resource's owner == subscription.retailCustomer AND resource-type ∈ granted customer-FBs.

    PR-B decisions consolidated

    1. SubscriptionEntity.authorization @OneToOne → @ManyToOne (two subscriptions : one authorization, driven by subscriptions.authorization_id); drop/deprecate the single authorizations.subscription_id.
    2. Customer-subscription representation: derive-from-scope (a) unless granular PII selection is required.
    3. Both are Flyway + entity changes in openespi-common.
  11. dfcoffin commented on May 28, 2026

    @dfcoffin
    ContributorAuthor

    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 no initiate_login_uri/IdP-initiated parameters). The TP then begins a fresh, standard /authorize request 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:

    • It is the canonical authorization-code flow every conformant TP must implement — exactly the conformance surface a GBA sandbox should exercise.
    • It is mandated by NAESB ESPI (above), so any IdP-initiated/portal-initiated handoff would be non-compliant.
    • Smallest PR C surface (one redirect contract, no portal→TP initiation endpoint).

    The redirect chain (TP is the origin throughout)

    TP  ──/authorize(client_id=TP, redirect_uri=TP, scope=FB=…)──▶  AS
    AS  ──302 (principal not authenticated)──────────────────────▶  DC login        [loginPage hook]
    DC  ──Spring Security login + shared session─────────────────▶  back to AS /authorize
    AS  ──302 (consent required)─────────────────────────────────▶  DC Authorization Screen  [consentPage hook]
    DC  ──POST granted (downscoped) scope─────────────────────────▶  AS /oauth2/authorize
    AS  ──302 code───────────────────────────────────────────────▶  TP redirect_uri
    TP  ──/oauth2/token(code, client_secret)─────────────────────▶  AS
    AS  ──(token-mint) back-channel: materialize Subscription/Authorization──▶  DC
    DC  ──{ resource_uri, authorization_uri, customer_resource_uri }─────────▶  AS
    AS  ──200 { access_token (opaque), token_type=Bearer, scope=FB=… }──────▶  TP
    

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

    1. Login delegation — 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.
    2. Consent delegation (Authorization Screen) — 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.
    3. Shared authenticated session (precondition) — the principal who approves on the DC must be the same Authentication the 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 shared HttpSession.

    Scope handling

    • Downscope rule: Spring AS enforces 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.
    • AS stays scope-opaque: the AS never parses 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 in openespi-common, consumed by the DC). openespi-authserver keeps no dependency on openespi-common and no FunctionBlock reference — 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:

    • DC persists { correlation_id (= AS state), granted_scope, selected_usagepoint_uris, customer_uri } at consent time.
    • At /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 }.
    • DC materializes the Subscription + Authorization records and returns { resource_uri, authorization_uri, customer_resource_uri }.
    • The AS attaches those URIs to the token's introspection payload — the home 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

    PR Owns Consumes the FB catalog?
    A EspiScope parser + FunctionBlock/FunctionBlockCategory catalog in openespi-common, unit-tested — (it is the catalog)
    B DC subscription-create back-channel endpoint; SubscriptionEntity.authorization @OneToOne→@ManyToOne; customer-subscription derive-from-scope yes
    C AS consentPage/loginPage delegation config + DC login & Authorization Screen (candidate resolution); shared-session wiring yes (DC side)
    D Token-response augmentation on the opaque path (migrate EspiTokenCustomizer URI logic) indirectly
    E Introspection-response enrichment (granted scope + URIs) indirectly
    F Enforcement guard + @PreAuthorize wiring (resource ∈ subscription UsagePoints AND data-shape ∈ granted FBs); replaces SCOPE_FB_15_READ_3rd_party shorthand yes

    Out of scope (deferred)

    • Utility-portal-initiated / IdP-initiated handoff — non-compliant per NAESB; portal→TP is bare navigation only.
    • Multi-DC routing (future; common AS fronting multiple DC sandboxes).
    • OIDC federation for TP↔separate-utility registration (future).
  12. added 8 commits that reference this issue on May 28, 2026
  13. added a commit that references this issue on Jun 4, 2026
    02e03ec
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ESPI 4.0Touches the NAESB ESPI 4.0 implementationblockingBlocks other work or CIinfrastructureCI, build, deployment, or developer tooling

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions