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
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,19 @@ All cuioss repositories have branch protection on `main`. Direct pushes to `main
2. Commit changes: `git add <files> && git commit -m "<message>"`
3. Push the branch: `git push -u origin <branch-name>`
4. Create a PR: `gh pr create --repo cuioss/API-Sheriff --head <branch-name> --base main --title "<title>" --body "<body>"`
5. Wait for CI + Gemini review (waits until checks complete): `gh pr checks --watch`
6. **Handle Gemini review comments** — fetch with `gh api repos/cuioss/API-Sheriff/pulls/<pr-number>/comments` and for each:
- If clearly valid and fixable: fix it, commit, push, then reply explaining the fix and resolve the comment
5. Wait for CI and the three automated reviewers — CodeRabbit, Sourcery and PR-Agent (waits until checks complete): `gh pr checks --watch`
6. **Handle the automated review comments from all three reviewers** — CodeRabbit, Sourcery and PR-Agent each comment independently, so treat the union of their comments as the work list. Fetch with `gh api repos/cuioss/API-Sheriff/pulls/<pr-number>/comments` and for each comment, whichever bot authored it:
- If clearly valid and fixable: fix it, commit, push, then reply explaining the fix and resolve the comment — a bot-fix commit is a commit, so the **Pre-Commit Process** section above applies unchanged
- If disagree or out of scope: reply explaining why, then resolve the comment
- If uncertain (not 100% confident): **ask the user** before acting
- Every comment MUST get a reply (reason for fix or reason for not fixing) and MUST be resolved
- **Re-review after pushing fixes is not uniform**: CodeRabbit and Sourcery re-review automatically on push; PR-Agent deliberately does **not** (`.github/workflows/pr-agent.yml` triggers only on `opened`/`reopened`/`ready_for_review` plus on-demand `issue_comment` commands). Re-request a PR-Agent pass explicitly by posting a `/review` comment on the PR after the fix push.
Comment thread
cuioss-oliver marked this conversation as resolved.
7. Do **NOT** enable auto-merge unless explicitly instructed. Wait for user approval.
8. Return to main: `git checkout main && git pull`
8. **After the merge lands, verify the post-merge state**:
- Re-check the **PR-attached** post-merge run. `.github/workflows/benchmark.yml` is `pull_request: types: [closed]` gated on `merged == true`, so its run stays attached to the PR and remains visible through the CI abstraction after the merge.
- Also check the **main-branch** post-merge run, which is *not* attached to the PR. `maven.yml` triggers on `push: branches: [main]`, and its `deploy-snapshot` job is skipped on pull requests but runs on that push — so a snapshot-deployment failure appears only in the Maven Build run for the merge commit on `main`, never on the PR. Look it up by the merge commit rather than by PR number.
- Assert zero unresolved review threads: `python3 .plan/execute-script.py plan-marshall:tools-integration-ci:ci pr comments --pr-number <n> --unresolved-only`. Route any straggler into a follow-up issue or plan rather than leaving it unresolved.
9. Return to main: `git checkout main && git pull`

## Temporary Files

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
Expand Down Expand Up @@ -63,8 +64,9 @@
* construction no previous key, so no cookie can carry a retired key id and this path never fires.
* <p>
* <strong>Session identity.</strong> Per the seam's single identity model, the reconstructed
* {@link SessionRecord#sessionId()} is a keyed digest over the sealed payload's login instant and
* {@code sub}. It is stable for the life of the session — so the refresh coordinator's single-flight
* {@link SessionRecord#sessionId()} is a keyed digest over the sealed payload's login instant,
* {@code sub} and the per-session nonce minted once at login. It is stable for the life of the
* session — so the refresh coordinator's single-flight
* coalescing behaves exactly as in server mode — and is <strong>never emitted to the browser</strong>:
* only the sealed value crosses. Single-flight is necessarily <em>per instance</em> here; a
* cross-instance duplicate refresh cannot be prevented without shared state and is this variant's
Expand All @@ -80,6 +82,14 @@ public final class CookieSessionBinding implements SessionBinding {
private static final String DIGEST_ALGORITHM = "SHA-256";
private static final int IDENTITY_BYTES = 16;

/**
* The per-session nonce width, matched to {@code SessionRecord.newSessionId()}'s 32 bytes so the
* cookie-mode nonce carries the same entropy as the server-mode session id it stands in for.
*/
private static final int SESSION_NONCE_BYTES = 32;

private static final SecureRandom SECURE_RANDOM = new SecureRandom();

/** The bounded, non-sensitive disposition recorded when a retired-key session is rolled over. */
private static final String RESEAL_DISPOSITION = "previous-key rollover";

Expand Down Expand Up @@ -110,8 +120,10 @@ public CookieSessionBinding(SealedSessionCookieCodec codec, byte[] identitySalt)
public BoundSession bind(SessionRecord session, Instant now) {
Objects.requireNonNull(session, "session");
Objects.requireNonNull(now, "now");
// A fresh login anchors the absolute lifetime at this instant.
return seal(payloadOf(session, now), now);
// A fresh login anchors the absolute lifetime at this instant and mints the session's one
// nonce. Minting here — once, on the login path only — is what makes two logins by the same
// subject within a single clock second resolve to distinct identities.
return seal(payloadOf(session, now, newSessionNonce()), now);
}

@Override
Expand All @@ -131,6 +143,11 @@ public Optional<SessionRecord> resolve(@Nullable String cookieHeader, Instant no
* the new sealed value. The original login instant is re-derived from the record's absolute
* deadline rather than taken from {@code now}, so an endlessly-refreshed session still dies at
* its original absolute deadline: a refresh rotates the tokens, never the session's lifetime.
* <p>
* The session nonce is re-sealed <strong>verbatim</strong> and never re-minted, for the same
* reason the login instant is re-derived rather than refreshed: both are identity inputs, so
* minting a new nonce here would change the derived {@link SessionRecord#sessionId()} mid-session
* and break the refresh coordinator's single-flight coalescing.
*
* @param rotated the session carrying the rotated token material
* @param now the reference instant, used only for the cookie's remaining {@code Max-Age}
Expand All @@ -141,7 +158,13 @@ public BoundSession persist(SessionRecord rotated, Instant now) {
Objects.requireNonNull(rotated, "rotated");
Objects.requireNonNull(now, "now");
Instant loginInstant = rotated.expiresAt().minus(codec.sessionTtl());
return seal(payloadOf(rotated, loginInstant), now);
// Fail loud rather than mint a replacement: a cookie-mode record always carries the nonce
// sealed at login, so an absent one means a caller reconstructed the record and dropped it —
// silently re-minting would change the session's identity mid-flight.
String sessionNonce = rotated.sessionNonce().orElseThrow(() -> new IllegalStateException(
"cookie-mode session record carries no sessionNonce — cannot re-seal without changing "
+ "the derived session identity"));
return seal(payloadOf(rotated, loginInstant, sessionNonce), now);
}

@Override
Expand Down Expand Up @@ -217,14 +240,26 @@ private SessionRecord markForReseal(SealedSessionCookieCodec.Unsealed unsealed)
return toSessionRecord(unsealed.payload());
}

private static SealedSessionPayload payloadOf(SessionRecord session, Instant loginInstant) {
private static SealedSessionPayload payloadOf(SessionRecord session, Instant loginInstant,
String sessionNonce) {
return new SealedSessionPayload(session.accessToken(), session.refreshToken(), session.idToken(),
session.sub(), session.sid(), session.acr(), session.authTime(), loginInstant);
session.sub(), session.sid(), session.acr(), session.authTime(), loginInstant, sessionNonce);
}

/**
* Mints one session nonce: {@link #SESSION_NONCE_BYTES} secure-random bytes, base64url-encoded so
* it survives the payload's base64 field encoding unchanged.
*/
private static String newSessionNonce() {
byte[] bytes = new byte[SESSION_NONCE_BYTES];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

private SessionRecord toSessionRecord(SealedSessionPayload payload) {
return SessionRecord.builder()
.sessionId(derivedIdentity(payload))
.sessionNonce(Optional.of(payload.sessionNonce()))
.accessToken(payload.accessToken())
.refreshToken(payload.refreshToken())
.idToken(payload.idToken())
Expand All @@ -238,9 +273,15 @@ private SessionRecord toSessionRecord(SealedSessionPayload payload) {

/**
* Derives this binding's stable per-session identity: a salted digest over the sealed payload's
* login instant and {@code sub}. Both inputs are fixed for the life of the session, so the
* identity is stable across re-seals; the salt keeps it un-recomputable outside this gateway;
* and it is never emitted to the browser.
* login instant, {@code sub} and per-session nonce. All three inputs are fixed for the life of
* the session, so the identity is stable across re-seals; the salt keeps it un-recomputable
* outside this gateway; and it is never emitted to the browser.
* <p>
* The nonce is what makes the identity <em>unique</em> rather than merely stable: login instant
* and {@code sub} alone collide for two logins by the same subject inside one clock second, which
* would cross-wire the refresh coordinator's single-flight keying between two distinct sessions.
* The identity remains a digest — the raw nonce is never used as, or substituted for, the
* session id.
*/
private String derivedIdentity(SealedSessionPayload payload) {
MessageDigest digest;
Expand All @@ -253,6 +294,7 @@ private String derivedIdentity(SealedSessionPayload payload) {
digest.update(identitySalt);
digest.update(Long.toString(payload.loginInstant().getEpochSecond()).getBytes(StandardCharsets.UTF_8));
digest.update(payload.sub().getBytes(StandardCharsets.UTF_8));
digest.update(payload.sessionNonce().getBytes(StandardCharsets.UTF_8));
byte[] identity = new byte[IDENTITY_BYTES];
System.arraycopy(digest.digest(), 0, identity, 0, IDENTITY_BYTES);
return Base64.getUrlEncoder().withoutPadding().encodeToString(identity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,19 @@ public final class SealedSessionCookieCodec {

private static final CuiLogger LOGGER = new CuiLogger(SealedSessionCookieCodec.class);

/** The current sealed-cookie format version, bound into the GCM associated data. */
public static final byte FORMAT_VERSION = 1;
/**
* The current sealed-cookie format version, bound into the GCM associated data.
* <p>
* Version {@code 2} carries the nine-field payload that added the per-session nonce keying the
* derived session identity. The bump is a clean break with no migration path: {@link #unseal}
* reads the leading version byte and rejects anything other than {@code FORMAT_VERSION} at an
* explicit gate, before a {@link Cipher} is even constructed — so a version-1 cookie is refused
* outright rather than being mis-parsed against the nine-field shape, and decryption is never
* attempted for it. The version byte is additionally bound into the GCM associated data, so it
* cannot be forged onto a value sealed under a different version either. Pre-existing cookie
* sessions are therefore refused fail-closed and the browser simply re-logs in.
*/
public static final byte FORMAT_VERSION = 2;

private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int NONCE_BYTES = 12;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,25 +51,42 @@
* @param acr the authentication context class, empty when absent
* @param authTime the IdP authentication instant, empty when absent
* @param loginInstant the absolute login instant anchoring the server-enforced session TTL
* @param sessionNonce the per-session random nonce minted once at login, folded into the derived
* session identity so two logins by the same subject within one clock second
* cannot collide. Re-sealed verbatim — never re-minted — so the identity is
* stable for the life of the session
* @author API Sheriff Team
* @since 1.0
*/
public record SealedSessionPayload(String accessToken, Optional<String> refreshToken, String idToken,
String sub, Optional<String> sid, Optional<String> acr, Optional<Instant> authTime, Instant loginInstant) {
String sub, Optional<String> sid, Optional<String> acr, Optional<Instant> authTime, Instant loginInstant,
String sessionNonce) {

private static final String REDACTED = "***REDACTED***";
private static final char FIELD_SEPARATOR = '\n';
private static final int FIELD_COUNT = 8;
private static final int FIELD_COUNT = 9;

/**
* Canonical constructor rejecting absent mandatory components and normalizing absent optionals
* to {@link Optional#empty()}.
* <p>
* {@code sessionNonce} is additionally rejected when blank: it keys the derived session
* identity, so an empty value would silently degrade that identity back to the colliding
* pre-nonce shape instead of failing. The nonce value itself never reaches the exception
* message.
*
* @throws NullPointerException when a mandatory component is {@code null}
* @throws IllegalArgumentException when {@code sessionNonce} is blank
*/
public SealedSessionPayload {
Objects.requireNonNull(accessToken, "accessToken");
Objects.requireNonNull(idToken, "idToken");
Objects.requireNonNull(sub, "sub");
Objects.requireNonNull(loginInstant, "loginInstant");
Objects.requireNonNull(sessionNonce, "sessionNonce");
if (sessionNonce.isBlank()) {
throw new IllegalArgumentException("sessionNonce must not be blank");
}
refreshToken = Objects.requireNonNullElse(refreshToken, Optional.empty());
sid = Objects.requireNonNullElse(sid, Optional.empty());
acr = Objects.requireNonNullElse(acr, Optional.empty());
Expand All @@ -91,16 +108,23 @@ public byte[] encode() {
appendField(encoded, acr.orElse(""));
appendField(encoded, authTime.map(instant -> Long.toString(instant.getEpochSecond())).orElse(""));
appendField(encoded, Long.toString(loginInstant.getEpochSecond()));
appendField(encoded, sessionNonce);
return encoded.toString().getBytes(StandardCharsets.UTF_8);
}

/**
* Reads a payload back from the wire form. The input MUST already have been authenticated by the
* codec's GCM tag — this method is not a parser for untrusted input.
*
* The field-count guard is strict: only the current nine-field shape is accepted. There is no
* legacy eight-field (pre-nonce) acceptance path — a clean break, so a payload predating the
* session nonce is rejected outright rather than admitted with a synthesized nonce that would
* silently change the derived session identity.
*
* @param encoded the UTF-8 bytes produced by {@link #encode()}
* @return the decoded payload; empty when the bytes do not carry the expected field shape
* (a defensive guard against a key that authenticates a foreign format)
* @return the decoded payload; empty when the bytes do not carry the expected field shape or
* carry a blank session nonce (a defensive guard against a key that authenticates a
* foreign format)
*/
public static Optional<SealedSessionPayload> decode(byte[] encoded) {
Objects.requireNonNull(encoded, "encoded");
Expand All @@ -117,10 +141,12 @@ public static Optional<SealedSessionPayload> decode(byte[] encoded) {
optionalField(fields[4]),
optionalField(fields[5]),
optionalField(fields[6]).map(seconds -> Instant.ofEpochSecond(Long.parseLong(seconds))),
Instant.ofEpochSecond(Long.parseLong(decodeField(fields[7])))));
Instant.ofEpochSecond(Long.parseLong(decodeField(fields[7]))),
decodeField(fields[8])));
} catch (IllegalArgumentException | DateTimeException _) {
// Base64 decode failure, epoch-second parse failure, or an epoch second that parses as a
// long but lies outside Instant's supported range, on bytes that authenticated: a
// Base64 decode failure, epoch-second parse failure, an epoch second that parses as a
// long but lies outside Instant's supported range, or a blank session nonce rejected by
// the canonical constructor, on bytes that authenticated: a
// foreign payload format under the same key. DateTimeException is NOT an
// IllegalArgumentException, so it must be caught explicitly or it escapes unseal() as an
// unhandled request error. Report "no session" rather than propagating.
Expand All @@ -143,17 +169,18 @@ public boolean isExpired(Duration ttl, Instant now) {
}

/**
* Overridden to redact every credential — the three tokens. The default record
* Overridden to redact every credential — the three tokens — plus the session nonce, which keys
* the derived session identity and so is treated as secret material. The default record
* {@code toString()} would otherwise print the raw token material into any log line, exception
* message, or debugger view.
*
* @return a string representation with all credential-bearing fields redacted
*/
@Override
public String toString() {
return "SealedSessionPayload[accessToken=%s, refreshToken=%s, idToken=%s, sub=%s, sid=%s, acr=%s, authTime=%s, loginInstant=%s]"
return "SealedSessionPayload[accessToken=%s, refreshToken=%s, idToken=%s, sub=%s, sid=%s, acr=%s, authTime=%s, loginInstant=%s, sessionNonce=%s]"
.formatted(REDACTED, refreshToken.isPresent() ? "Optional[" + REDACTED + "]" : "Optional.empty",
REDACTED, sub, sid, acr, authTime, loginInstant);
REDACTED, sub, sid, acr, authTime, loginInstant, REDACTED);
}

private static void appendField(StringBuilder target, String value) {
Expand Down
Loading
Loading