Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2025 Green Button Alliance, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.greenbuttonalliance.espi.common.handoff;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.Persistable;

import java.io.Serializable;
import java.time.Instant;

/**
* Tracks a single-use nonce from a verified {@link SignedHandoff} on the receiver side. A row in
* this table means "this nonce has been consumed; further attempts must be rejected as replay."
*
* <p>{@link #nonce} is the primary key; an attempt to insert a duplicate fails the transaction and
* surfaces as a replay rejection. The receiver is expected to delete rows past {@link #expiresAt}
* via a periodic sweep (out of scope for PR C1).</p>
*/
@Entity
@Table(name = "handoff_nonces")
@Getter
@Setter
@NoArgsConstructor
public class HandoffNonceEntity implements Persistable<String>, Serializable {

private static final long serialVersionUID = 1L;

/** The base64URL-encoded nonce from the verified handoff payload. */
@Id
@Column(name = "nonce", length = 64, nullable = false, updatable = false)
private String nonce;

/**
* The {@code expiresAt} from the verified handoff payload. Used by a sweep job (not in scope
* here) to reap expired rows. NOT consulted on consume &mdash; the codec already verified
* expiry before this row was written.
*/
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;

/** When this nonce was consumed; recorded for audit. */
@Column(name = "consumed_at", nullable = false, updatable = false)
private Instant consumedAt;

public HandoffNonceEntity(String nonce, Instant expiresAt, Instant consumedAt) {
this.nonce = nonce;
this.expiresAt = expiresAt;
this.consumedAt = consumedAt;
}

@Override
public String getId() {
return nonce;
}

/**
* Always {@code true} so {@code JpaRepository.save()} routes to
* {@code entityManager.persist()} (INSERT) instead of {@code merge()} (UPSERT). A duplicate
* nonce must surface as a PK violation, not silently update an existing row.
*/
@Override
@Transient
public boolean isNew() {
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright 2025 Green Button Alliance, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.greenbuttonalliance.espi.common.handoff;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.time.Instant;

@Repository
public interface HandoffNonceRepository extends JpaRepository<HandoffNonceEntity, String> {

/**
* Delete all nonces whose expiry is in the past. Returns the number of rows removed.
* Intended for a periodic sweep job (out of scope for PR C1; included as a hook).
*/
long deleteByExpiresAtBefore(Instant cutoff);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright 2025 Green Button Alliance, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.greenbuttonalliance.espi.common.handoff;

import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.Base64;

/**
* Single-use nonce semantics for verified {@link SignedHandoff} payloads.
*
* <p>{@link #consume} writes a row keyed by the nonce; the PK uniqueness constraint atomically
* detects a replay and throws {@link InvalidHandoffException}. Each {@code consume} runs in its
* own transaction so the row commits even if a surrounding business transaction rolls back &mdash;
* a partially-completed grant must not allow the same nonce to be reused.</p>
*
* <p>{@link #generate} produces a fresh 128-bit base64URL nonce for use when issuing an outbound
* handoff. Issuers do NOT pre-insert a row &mdash; the receiver's {@code consume} is what closes
* the loop.</p>
*/
@Service
@RequiredArgsConstructor
public class HandoffNonceService {

private static final int NONCE_BYTES = 16;
private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();

private final HandoffNonceRepository repository;
private final SecureRandom random = new SecureRandom();
private final Clock clock = Clock.systemUTC();

/**
* Generate a fresh 128-bit base64URL-encoded nonce. Callers attach this to a new outbound
* handoff payload; the receiver atomically detects replay via {@link #consume}.
*/
public String generate() {
byte[] bytes = new byte[NONCE_BYTES];
random.nextBytes(bytes);
return URL_ENCODER.encodeToString(bytes);
}

/**
* Mark a nonce as consumed. Runs in a new transaction so the row commits independently of any
* surrounding business transaction &mdash; we must never allow a nonce to be re-consumable
* because a downstream step failed.
*
* @param nonce the nonce from a verified handoff payload
* @param expiresAt the {@code expiresAt} from the same payload, recorded for the sweep job
* @throws InvalidHandoffException if this nonce has already been consumed (replay)
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void consume(String nonce, Instant expiresAt) {
if (nonce == null || nonce.isBlank()) {
throw new InvalidHandoffException("nonce is empty");
}
try {
repository.save(new HandoffNonceEntity(nonce, expiresAt, clock.instant()));
repository.flush();
}
catch (DataIntegrityViolationException replay) {
throw new InvalidHandoffException("nonce already consumed (replay)", replay);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2025 Green Button Alliance, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.greenbuttonalliance.espi.common.handoff;

/**
* Thrown when a signed handoff token is malformed, tampered, expired, replayed, or otherwise
* rejected. Callers should treat this as a 400 / "go back to start" condition &mdash; never reveal
* which sub-check failed to the user-agent.
*/
public class InvalidHandoffException extends RuntimeException {

public InvalidHandoffException(String message) {
super(message);
}

public InvalidHandoffException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright 2025 Green Button Alliance, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.greenbuttonalliance.espi.common.handoff;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

/**
* Tamper-evident value object carried as a URL parameter between the GBA Authorization Server and a
* Data Custodian sandbox during the customer-facing OAuth2 flow.
*
* <h2>Why not a shared session?</h2>
* AS and DC are independently deployed Spring Boot applications. A shared JDBC/Redis Spring Session
* would force cookie-domain coupling, SameSite=None in dev, and an ambiguous "whose principal lives
* in the session" answer. The signed-handoff approach borrows the same idea OAuth2 itself uses for
* the {@code state} parameter: encode the cross-app state in the URL, sign it with HMAC, let each
* side keep its own session. Replay is prevented by a single-use nonce table on the receiver.
*
* <h2>Wire format</h2>
* Two dot-separated base64URL segments:
* <pre>
* {base64URL(JSON(payload))} . {base64URL(HMAC-SHA256(key, base64URL(JSON(payload))))}
* </pre>
* Identical wire format on both sides; the codec is duplicated by design because {@code
* openespi-authserver} is independent of {@code openespi-common}. The JSON payload structure below
* is the contract.
*
* <h2>Directions</h2>
* <ul>
* <li>{@link Outbound} &mdash; AS to DC ("log this customer in and have them consent to this scope")</li>
* <li>{@link Return} &mdash; DC to AS ("customer logged in and made these choices")</li>
* </ul>
*
* <h2>Lifetime</h2>
* Issued with a short expiry (default 5 minutes) and a 128-bit random nonce. The receiver verifies
* the signature, rejects expired payloads, and consumes the nonce single-use via
* {@link HandoffNonceService}.
*
* @see SignedHandoffCodec
* @see HandoffNonceService
*/
public sealed interface SignedHandoff permits SignedHandoff.Outbound, SignedHandoff.Return {

/** Wire-format version. Increment when changing the JSON shape in a non-backwards-compatible way. */
int CURRENT_VERSION = 1;

/** Wire-format direction tag for {@link Outbound} payloads (AS &rarr; DC). */
String DIRECTION_OUTBOUND = "outbound";

/** Wire-format direction tag for {@link Return} payloads (DC &rarr; AS). */
String DIRECTION_RETURN = "return";

int version();
String direction();
String correlationId();
Instant issuedAt();
Instant expiresAt();
String nonce();

/**
* AS &rarr; DC handoff. Encodes the granted scope, the client_id, and the URL the DC should
* redirect the user back to after login + Authorization Screen.
*
* @param version wire-format version, must equal {@link #CURRENT_VERSION}
* @param correlationId opaque AS-supplied trace id, echoed on the return handoff
* @param issuedAt issuance timestamp (epoch seconds)
* @param expiresAt expiry timestamp (epoch seconds); receiver rejects past this
* @param nonce base64URL-encoded 128 bits; receiver tracks single-use
* @param clientId OAuth2 {@code client_id} of the requesting third party
* @param grantedScope ESPI scope string the customer is being asked to approve
* @param returnUrl absolute URL the DC redirects the user-agent to on completion
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
record Outbound(
@JsonProperty("v") int version,
@JsonProperty("dir") String direction,
@JsonProperty("cid") String correlationId,
@JsonProperty("iat") Instant issuedAt,
@JsonProperty("exp") Instant expiresAt,
@JsonProperty("nonce") String nonce,
@JsonProperty("client_id") String clientId,
@JsonProperty("scope") String grantedScope,
@JsonProperty("return_url") String returnUrl
) implements SignedHandoff {

/** Convenience factory that fills in {@code version} and {@code direction}. */
public static Outbound of(String correlationId, Instant issuedAt, Instant expiresAt, String nonce,
String clientId, String grantedScope, String returnUrl) {
return new Outbound(CURRENT_VERSION, DIRECTION_OUTBOUND, correlationId, issuedAt, expiresAt,
nonce, clientId, grantedScope, returnUrl);
}
}

/**
* DC &rarr; AS handoff. Encodes the authenticated principal and the customer's selections from
* the Authorization Screen (or a deny decision).
*
* @param version wire-format version, must equal {@link #CURRENT_VERSION}
* @param correlationId the AS-supplied trace id from the corresponding outbound
* @param issuedAt issuance timestamp
* @param expiresAt expiry timestamp
* @param nonce base64URL-encoded 128 bits; AS tracks single-use
* @param principal DC's authenticated retail-customer identifier
* @param selectedUsagePointIds usage points the customer chose to share; empty list for a
* PII-only or denied grant
* @param customerResourceUri absolute URI of the customer/PII resource the customer
* approved sharing, or {@code null} if not granted
* @param consent {@code "allow"} if the customer granted; {@code "deny"} if not
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
record Return(
@JsonProperty("v") int version,
@JsonProperty("dir") String direction,
@JsonProperty("cid") String correlationId,
@JsonProperty("iat") Instant issuedAt,
@JsonProperty("exp") Instant expiresAt,
@JsonProperty("nonce") String nonce,
@JsonProperty("sub") String principal,
@JsonProperty("up") List<UUID> selectedUsagePointIds,
@JsonProperty("cust_uri") String customerResourceUri,
@JsonProperty("consent") String consent
) implements SignedHandoff {

public static final String CONSENT_ALLOW = "allow";
public static final String CONSENT_DENY = "deny";

/** Convenience factory that fills in {@code version} and {@code direction}. */
public static Return of(String correlationId, Instant issuedAt, Instant expiresAt, String nonce,
String principal, List<UUID> selectedUsagePointIds,
String customerResourceUri, String consent) {
return new Return(CURRENT_VERSION, DIRECTION_RETURN, correlationId, issuedAt, expiresAt,
nonce, principal, selectedUsagePointIds, customerResourceUri, consent);
}
}
}
Loading
Loading