This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new bb04918626 MCP 2026-07-28 MRTR principal-bound requestState AAD 
(TODO-325)
bb04918626 is described below

commit bb04918626e4be981ceedcb8285cbad9c2682a87
Author: James Bognar <[email protected]>
AuthorDate: Wed Aug 5 15:56:02 2026 -0700

    MCP 2026-07-28 MRTR principal-bound requestState AAD (TODO-325)
    
    Co-authored-by: Cursor <[email protected]>
---
 .../mcp/v20260728/AeadRequestStateCodec.java       | 117 +++++++++++++---
 .../mcp/v20260728/AeadRequestStateCodec_Test.java  | 155 ++++++++++++++++++---
 .../v20260728/McpResourceServerBinding_Test.java   |  65 +++++++++
 3 files changed, 301 insertions(+), 36 deletions(-)

diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec.java
index b18c54aa71..19a4962043 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec.java
@@ -28,6 +28,7 @@ import javax.crypto.Cipher;
 import javax.crypto.spec.GCMParameterSpec;
 
 import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.rest.server.auth.ClaimsPrincipal;
 
 /**
  * Built-in default {@link RequestStateCodec}: AES-256-GCM keyed by a 
pluggable {@link KeyProvider}.
@@ -53,17 +54,23 @@ import org.apache.juneau.marshall.marshaller.Json;
  * <p>
  * The AAD passed to {@link #seal}/{@link #unseal} is authenticated but never 
encrypted (standard AES-GCM AAD
  * semantics). The dispatcher passes the canonical {@code method + '\u0000' + 
protocolVersion} (NUL-separated)
- * form as the caller-supplied AAD (see {@code McpRevision#aad}); this codec 
appends {@code '\u0000' + keyId}
- * to that value before passing it to the cipher, per {@link KeyProvider}'s 
implicit {@code keyId}-authentication
- * contract.
+ * form as the caller-supplied AAD (see {@code McpRevision#aad}); this codec 
composes the caller-supplied
+ * {@code aad}, the {@link KeyProvider}'s {@code keyId} (per its implicit 
{@code keyId}-authentication contract),
+ * and {@code principalIdentity(principal)} (the TODO-325 principal binding, 
below) into the cipher's actual AAD
+ * using the same self-delimiting, length-prefixed framing described on {@link 
#principalIdentity(Principal)}
+ * &mdash; so the three-field outer composition is unambiguous exactly as the 
two-field inner one is, even if a
+ * field happened to contain a NUL.
  *
  * <p>
- * <b>Authenticated principal (READY-312f F4).</b> {@link #seal}/{@link 
#unseal} receive the caller's authenticated
- * {@link Principal} (nullable) per the {@link RequestStateCodec} contract, 
but this built-in codec does <b>not</b>
- * yet fold it into the AEAD's authenticated data &mdash; the token is not 
principal-bound. Adding that binding
- * (and choosing which identity attribute to bind) is owned by TODO-325; the 
{@code seal}/{@code unseal} bodies
- * carry a {@code TODO-325} marker at the exact fold point. F4's guarantee is 
only that the principal reaches the
- * codec at both seal and unseal.
+ * <b>Principal-bound AAD (TODO-325).</b> {@link #seal}/{@link #unseal} fold 
the caller's authenticated
+ * {@link Principal} identity into the AEAD's authenticated data, so a {@code 
requestState} minted for one caller
+ * cannot be resumed by another: a mismatched principal fails the GCM tag 
check and {@link #unseal} returns
+ * {@link Optional#empty()}. The bound identity is a canonical, deterministic 
{@code iss|sub} (issuer + subject)
+ * string derived by {@link #principalIdentity(Principal)} &mdash; 
issuer-scoped so a bare {@code sub} cannot
+ * collide across IdPs. A <jk>null</jk> (anonymous / RS-auth-disabled) 
principal binds <b>fail-closed</b> to a
+ * fixed {@code "anonymous"} sentinel rather than skipping the binding, so an 
anonymous-sealed token and an
+ * authenticated-sealed token are never mutually resumable 
(anonymous&harr;anonymous still round-trips). See
+ * {@link #principalIdentity(Principal)} for the exact canonical form and its 
collision-safety guarantee.
  *
  * <p>
  * Random per-seal nonces are safe up to roughly 2^32 seals under a single key 
(the AES-GCM birthday bound). The
@@ -105,6 +112,12 @@ public class AeadRequestStateCodec implements 
RequestStateCodec {
        private static final int MIN_TOKEN_CHARS = VERSION.length() + 1 + 2 + 1 
+ NONCE_B64_CHARS + 1 + 22;
        private static final int MAX_TOKEN_CHARS = 64 * 1024;
 
+       // TODO-325 anonymous-caller sentinel (settled decision 2). A null 
principal binds fail-closed to this fixed
+       // identity rather than skipping the binding. It is intentionally a 
plain literal with no NUL: a real
+       // principalIdentity(...) is always length-prefixed and begins with a 
decimal digit and contains NULs, so the
+       // sentinel can never collide with any derived identity.
+       static final String ANONYMOUS_IDENTITY = "anonymous";
+
        private static final Base64.Encoder B64URL = 
Base64.getUrlEncoder().withoutPadding();
 
        private final KeyProvider keyProvider;
@@ -140,10 +153,11 @@ public class AeadRequestStateCodec implements 
RequestStateCodec {
                        random.nextBytes(nonce);
                        var cipher = Cipher.getInstance(ALGORITHM);
                        cipher.init(Cipher.ENCRYPT_MODE, ks.key(), new 
GCMParameterSpec(GCM_TAG_BITS, nonce));
-                       // TODO-325: the authenticated principal is exposed 
here (READY-312f F4) but not yet folded into the AEAD's
-                       // authenticated data. Binding it (e.g. appending a 
chosen identity attribute to the updateAAD(...) input
-                       // below, mirroring the keyId append) is owned by 
TODO-325, along with the choice of which identity to bind.
-                       cipher.updateAAD((aad + '\u0000' + 
ks.keyId()).getBytes(StandardCharsets.UTF_8));
+                       // TODO-325: fold the authenticated caller identity 
into the AEAD's authenticated data, so a token
+                       // minted for one principal cannot be resumed by 
another. unseal derives the SAME identity below; a
+                       // mismatch fails the GCM tag check there. The three 
fields are framed with lengthPrefixJoin (same
+                       // self-delimiting scheme as principalIdentity's 
iss|sub) so the outer composition is unambiguous too.
+                       cipher.updateAAD(lengthPrefixJoin(aad, ks.keyId(), 
principalIdentity(principal)).getBytes(StandardCharsets.UTF_8));
                        var plaintext = 
Json.of(state).getBytes(StandardCharsets.UTF_8);
                        var ciphertext = cipher.doFinal(plaintext);
                        var keyIdB64 = 
B64URL.encodeToString(ks.keyId().getBytes(StandardCharsets.UTF_8));
@@ -174,10 +188,10 @@ public class AeadRequestStateCodec implements 
RequestStateCodec {
                                return Optional.empty();
                        var cipher = Cipher.getInstance(ALGORITHM);
                        cipher.init(Cipher.DECRYPT_MODE, resolved.get(), new 
GCMParameterSpec(GCM_TAG_BITS, nonce));
-                       // TODO-325: the authenticated principal is exposed 
here (READY-312f F4) but not yet folded into the AEAD's
-                       // authenticated data. When TODO-325 binds it at seal 
time, this call site must fold the SAME identity into
-                       // updateAAD(...) below so a token minted for principal 
A fails the GCM tag check under principal B.
-                       cipher.updateAAD((aad + '\u0000' + 
keyId).getBytes(StandardCharsets.UTF_8));
+                       // TODO-325: fold the SAME caller identity seal bound 
in, so a token minted for principal A fails the GCM
+                       // tag check (and returns Optional.empty() below) when 
replayed under principal B or anonymously. Same
+                       // lengthPrefixJoin framing as seal, above.
+                       cipher.updateAAD(lengthPrefixJoin(aad, keyId, 
principalIdentity(principal)).getBytes(StandardCharsets.UTF_8));
                        var plaintext = cipher.doFinal(ciphertext);
                        return Optional.of(Json.to(new String(plaintext, 
StandardCharsets.UTF_8), McpRequestState.class));
                } catch (@SuppressWarnings("unused") Exception e) {
@@ -187,4 +201,73 @@ public class AeadRequestStateCodec implements 
RequestStateCodec {
                        return Optional.empty();
                }
        }
+
+       /**
+        * Derives the canonical, deterministic identity string this codec 
folds into the AEAD's authenticated data to
+        * bind a sealed {@code requestState} to its caller (TODO-325).
+        *
+        * <p>
+        * <b>Bound identity (settled decision 1): {@code iss|sub}.</b> When 
{@code principal} is a {@link ClaimsPrincipal}
+        * (the type the F2 resource-server layer produces &mdash; {@code 
JwtTokenValidator}, OAuth introspection, OIDC, and
+        * SAML all return one), the {@code iss} and {@code sub} claims are 
read from its claim set; the issuer scopes the
+        * subject so a bare {@code sub} cannot collide across IdPs. When 
{@code sub} is absent or empty (or the principal
+        * is a bare {@link Principal} that exposes only {@link 
Principal#getName() getName()}), {@link Principal#getName()
+        * getName()} is used as the subject with an empty issuer &mdash; a 
deterministic, collision-conscious fallback.
+        *
+        * <p>
+        * <b>Anonymous (settled decision 2): fixed sentinel, fail-closed.</b> 
A <jk>null</jk> principal (anonymous caller /
+        * RS auth disabled) binds to the fixed {@link #ANONYMOUS_IDENTITY} 
sentinel rather than skipping the binding, so an
+        * anonymous-sealed token and an authenticated-sealed token are never 
mutually resumable.
+        *
+        * <p>
+        * <b>Encoding (collision-safe).</b> The identity is a length-prefixed, 
fixed-order concatenation:
+        * {@code len(iss) + '\u0000' + iss + '\u0000' + len(sub) + '\u0000' + 
sub}. Prefixing each component with its
+        * length makes the encoding self-delimiting, so no {@code iss}/{@code 
sub} value that itself contains the NUL
+        * delimiter can push two distinct identities onto the same string 
(which a plain {@code iss + '\u0000' + sub} join
+        * could). Both {@link #seal} and {@link #unseal} derive this identical 
string for the same principal, so a matched
+        * caller round-trips and any mismatch (different subject, different 
issuer, or authenticated-vs-anonymous) yields
+        * different AAD bytes and fails GCM tag verification. The value is 
never logged or surfaced in an exception.
+        *
+        * @param principal The authenticated caller. May be <jk>null</jk> 
(binds to {@link #ANONYMOUS_IDENTITY}).
+        * @return The canonical identity string. Never <jk>null</jk>.
+        */
+       static String principalIdentity(Principal principal) {
+               if (principal == null)
+                       return ANONYMOUS_IDENTITY;
+               String iss;
+               String sub;
+               if (principal instanceof ClaimsPrincipal cp) {
+                       iss = cp.getClaim("iss", String.class).orElse("");
+                       sub = cp.getClaim("sub", String.class).orElse("");
+                       if (sub.isEmpty())
+                               sub = ein(cp.getName());
+               } else {
+                       iss = "";
+                       sub = ein(principal.getName());
+               }
+               return lengthPrefixJoin(iss, sub);
+       }
+
+       /**
+        * Joins the given fields into a single self-delimiting string: each 
field is preceded by its own decimal
+        * length, and every token (each length, and each field) is 
NUL-separated &mdash; equivalent to
+        * {@code String.join("\u0000", len(fields[0]), fields[0], 
len(fields[1]), fields[1], ...)}. Because a
+        * field's exact length is always known before its bytes are consumed, 
an embedded NUL inside a field can
+        * never be misread as a token boundary, so two distinct field tuples 
never join to the same string. Shared
+        * by {@link #principalIdentity(Principal)} (framing {@code iss}/{@code 
sub}) and by {@link #seal}/
+        * {@link #unseal} (framing the outer {@code aad}/{@code 
keyId}/identity triple).
+        *
+        * @param fields The fields to frame, in order. Must not be 
<jk>null</jk> and must not contain a <jk>null</jk>
+        *      element.
+        * @return The length-prefixed, NUL-joined encoding. Never 
<jk>null</jk>.
+        */
+       static String lengthPrefixJoin(String... fields) {
+               var sb = new StringBuilder();
+               for (var f : fields) {
+                       if (sb.length() > 0)
+                               sb.append('\u0000');
+                       sb.append(f.length()).append('\u0000').append(f);
+               }
+               return sb.toString();
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec_Test.java
index 3ced9355ff..1d3ae26091 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec_Test.java
@@ -26,6 +26,7 @@ import java.util.Map;
 import javax.crypto.KeyGenerator;
 
 import org.apache.juneau.marshall.collections.JsonMap;
+import org.apache.juneau.rest.server.auth.ClaimsPrincipal;
 import org.junit.jupiter.api.Test;
 
 /**
@@ -280,19 +281,19 @@ class AeadRequestStateCodec_Test {
        }
 
        /**
-        * Pins the current (READY-312f F4) contract documented on {@link 
AeadRequestStateCodec}'s class Javadoc and at
-        * the {@code TODO-325} markers in {@link AeadRequestStateCodec#seal} / 
{@link AeadRequestStateCodec#unseal}: the
-        * principal reaches the codec at both seal and unseal, but is <b>not 
yet</b> folded into the AEAD's authenticated
-        * data. A real {@link AeadRequestStateCodec} seal uses a random 
per-seal nonce (see the class Javadoc), so two
-        * {@code seal} calls are never byte-identical even with everything 
else held fixed &mdash; the built-in codec has
-        * no fixed-nonce affordance (unlike {@code 
Characterization_Test.FixedKeyGcmCodec}, a wholly separate, hardcoded
-        * fixture implementation, not this class). So this proves the 
equivalent invariant directly on ONE sealed token:
-        * it unseals successfully under the sealing principal, under a 
completely different principal, and under a
-        * <jk>null</jk> (anonymous) principal alike &mdash; i.e. the principal 
has no bearing on seal/unseal validity yet.
-        * Once TODO-325 binds the principal into the AAD, unsealing under 
{@code bob} or <jk>null</jk> here must start
-        * failing, which is exactly the regression this test is meant to catch.
+        * Pins the TODO-325 principal-bound-AAD contract: the caller's 
authenticated identity is folded into the AEAD's
+        * authenticated data at seal, so a token minted for one principal 
cannot be resumed by another. A real
+        * {@link AeadRequestStateCodec} seal uses a random per-seal nonce (see 
the class Javadoc), so two {@code seal}
+        * calls are never byte-identical even with everything else held fixed; 
this proves the binding invariant directly
+        * on ONE sealed token: it unseals successfully under the sealing 
principal, but fails ({@link Optional#empty()})
+        * under a completely different principal and under a <jk>null</jk> 
(anonymous) principal alike.
+        *
+        * <p>
+        * This is the inversion of the READY-312f F4 tripwire (formerly {@code 
a15_principalIsNotYetBoundSo...}): before
+        * TODO-325 landed, all three unseals succeeded; binding the identity 
into the AAD is exactly the change that flips
+        * the {@code bob} / <jk>null</jk> cases to failure.
         */
-       @Test void a15_principalIsNotYetBoundSoTokenUnsealsUnderAnyPrincipal() {
+       @Test void a15_principalIsBoundSoTokenRejectsDifferentPrincipal() {
                var a = new AeadRequestStateCodec();
                var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
                Principal alice = () -> "alice";
@@ -301,13 +302,129 @@ class AeadRequestStateCodec_Test {
                var underSamePrincipal = a.unseal(token, AAD, alice);
                var underDifferentPrincipal = a.unseal(token, AAD, bob);
                var underNullPrincipal = a.unseal(token, AAD, null);
-               assertTrue(underSamePrincipal.isPresent(), "round trip under 
the sealing principal must still succeed");
+               assertTrue(underSamePrincipal.isPresent(), "round trip under 
the sealing principal must succeed");
                assertEquals(state, underSamePrincipal.get());
-               assertTrue(underDifferentPrincipal.isPresent(),
-                       "principal is not yet bound to the AAD (TODO-325), so a 
different principal must still unseal");
-               assertEquals(state, underDifferentPrincipal.get());
-               assertTrue(underNullPrincipal.isPresent(),
-                       "principal is not yet bound to the AAD (TODO-325), so a 
null (anonymous) principal must still unseal");
-               assertEquals(state, underNullPrincipal.get());
+               assertTrue(underDifferentPrincipal.isEmpty(),
+                       "principal is bound to the AAD (TODO-325), so a 
different principal must fail GCM tag verification");
+               assertTrue(underNullPrincipal.isEmpty(),
+                       "principal is bound to the AAD (TODO-325), so a null 
(anonymous) principal must not unseal an authenticated-sealed token");
+       }
+
+       /**
+        * Pins the TODO-325 anonymous-caller policy (settled decision 2 
&mdash; fail-closed with a fixed sentinel): a
+        * <jk>null</jk> principal binds to a constant sentinel identity rather 
than "skip binding", so anonymous&harr;
+        * anonymous round-trips while anonymous&harr;authenticated is rejected 
in BOTH directions.
+        */
+       @Test void 
a16_anonymousSentinelRoundTripsButNeverCrossesAuthenticatedBoundary() {
+               var a = new AeadRequestStateCodec();
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               Principal alice = () -> "alice";
+
+               // Anonymous -> anonymous round-trips (the null sentinel is 
deterministic).
+               var anonToken = a.seal(state, AAD, null);
+               var anonUnderNull = a.unseal(anonToken, AAD, null);
+               assertTrue(anonUnderNull.isPresent(), "anonymous seal must 
unseal under a null (anonymous) principal");
+               assertEquals(state, anonUnderNull.get());
+
+               // Anonymous-sealed token must NOT unseal under an 
authenticated principal.
+               assertTrue(a.unseal(anonToken, AAD, alice).isEmpty(),
+                       "an anonymous-sealed token must not be resumable by an 
authenticated principal");
+
+               // Authenticated-sealed token must NOT unseal anonymously 
(already covered by a15's null case; asserted here
+               // from the opposite starting point for symmetry).
+               var aliceToken = a.seal(state, AAD, alice);
+               assertTrue(a.unseal(aliceToken, AAD, null).isEmpty(),
+                       "an authenticated-sealed token must not be resumable 
anonymously");
+       }
+
+       /**
+        * Pins cross-IdP collision safety at the codec level (settled decision 
1 &mdash; the bound identity is
+        * {@code iss|sub}, not a bare {@code sub}): two {@link 
ClaimsPrincipal}s sharing the same {@code sub} but issued by
+        * different {@code iss} are distinct identities, so a token sealed 
under one does not unseal under the other. Also
+        * confirms the exact-match case still round-trips.
+        */
+       @Test void a17_claimsPrincipalIssuerScopingPreventsCrossIdpResume() {
+               var a = new AeadRequestStateCodec();
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               Principal idpA = new ClaimsPrincipal("user-1", Map.of("iss", 
"https://idp-a.example.com";, "sub", "user-1"));
+               Principal idpB = new ClaimsPrincipal("user-1", Map.of("iss", 
"https://idp-b.example.com";, "sub", "user-1"));
+
+               var token = a.seal(state, AAD, idpA);
+               var sameIdp = a.unseal(token, AAD, idpA);
+               assertTrue(sameIdp.isPresent(), "same iss+sub must round-trip");
+               assertEquals(state, sameIdp.get());
+               assertTrue(a.unseal(token, AAD, idpB).isEmpty(),
+                       "same sub but a different iss is a different identity 
(cross-IdP collision safety), so unseal must fail");
+       }
+
+       // 
---------------------------------------------------------------------------------------------
+       // principalIdentity(...) helper (TODO-325): pins the exact canonical 
string for each derivation path so the
+       // AAD-bound identity shape is locked down independent of the 
seal/unseal round-trip above.
+       // 
---------------------------------------------------------------------------------------------
+
+       @Test void b01_identity_nullPrincipalIsFixedSentinel() {
+               assertEquals("anonymous", 
AeadRequestStateCodec.principalIdentity(null));
+       }
+
+       @Test void b02_identity_claimsPrincipalUsesLengthPrefixedIssAndSub() {
+               var p = new ClaimsPrincipal("ignored-name", Map.of("iss", "i", 
"sub", "s"));
+               // len(iss)=1 NUL iss NUL len(sub)=1 NUL sub
+               assertEquals("1\u0000i\u00001\u0000s", 
AeadRequestStateCodec.principalIdentity(p));
+       }
+
+       @Test void 
b03_identity_barePrincipalFallsBackToNameAsSubjectWithEmptyIssuer() {
+               Principal p = () -> "alice";
+               // iss empty (len 0), sub=name "alice" (len 5)
+               assertEquals("0\u0000\u00005\u0000alice", 
AeadRequestStateCodec.principalIdentity(p));
+       }
+
+       @Test void b04_identity_claimsPrincipalMissingSubFallsBackToName() {
+               // No sub claim -> getName() ("alice") is used as the subject; 
iss claim still honored.
+               var p = new ClaimsPrincipal("alice", Map.of("iss", 
"https://idp";));
+               var iss = "https://idp";;
+               assertEquals(iss.length() + "\u0000" + iss + "\u0000" + 
"5\u0000alice", AeadRequestStateCodec.principalIdentity(p));
+       }
+
+       @Test void b05_identity_differsByIssuerOnly() {
+               var a = new ClaimsPrincipal("n", Map.of("iss", "https://idp-a";, 
"sub", "same"));
+               var b = new ClaimsPrincipal("n", Map.of("iss", "https://idp-b";, 
"sub", "same"));
+               assertNotEquals(AeadRequestStateCodec.principalIdentity(a), 
AeadRequestStateCodec.principalIdentity(b),
+                       "two principals differing only in issuer must produce 
different identities (cross-IdP collision safety)");
+       }
+
+       @Test void b06_identity_differsBySubjectOnly() {
+               var a = new ClaimsPrincipal("n", Map.of("iss", "https://idp";, 
"sub", "user-1"));
+               var b = new ClaimsPrincipal("n", Map.of("iss", "https://idp";, 
"sub", "user-2"));
+               assertNotEquals(AeadRequestStateCodec.principalIdentity(a), 
AeadRequestStateCodec.principalIdentity(b),
+                       "two principals differing only in subject must produce 
different identities");
+       }
+
+       @Test void b07_identity_lengthPrefixDefeatsDelimiterCollision() {
+               // Without length-prefixing, (iss="a", sub="\u0000b") and 
(iss="a\u0000", sub="b") would both join to
+               // "a\u0000\u0000b" and collide.  The length prefixes keep them 
distinct.
+               var p1 = new ClaimsPrincipal("n", Map.of("iss", "a", "sub", 
"\u0000b"));
+               var p2 = new ClaimsPrincipal("n", Map.of("iss", "a\u0000", 
"sub", "b"));
+               assertNotEquals(AeadRequestStateCodec.principalIdentity(p1), 
AeadRequestStateCodec.principalIdentity(p2),
+                       "length-prefixed encoding must not let a NUL-bearing 
iss/sub collide two distinct identities");
+       }
+
+       // 
---------------------------------------------------------------------------------------------
+       // lengthPrefixJoin(...) helper: pins the shared framing seal/unseal 
use to compose the OUTER
+       // aad/keyId/identity AAD triple (the same self-delimiting scheme 
principalIdentity uses for iss|sub, above).
+       // 
---------------------------------------------------------------------------------------------
+
+       @Test void 
b08_lengthPrefixJoin_defeatsFieldBoundaryCollisionAcrossThreeFields() {
+               // Mirrors b07 one level up, at the outer 3-field (aad, keyId, 
identity) composition: without
+               // length-prefixing, ("a", "b", "\u0000c") and ("a", "b\u0000", 
"c") would both naively join to
+               // "a\u0000b\u0000\u0000c". The length prefixes keep them 
distinct.
+               var t1 = AeadRequestStateCodec.lengthPrefixJoin("a", "b", 
"\u0000c");
+               var t2 = AeadRequestStateCodec.lengthPrefixJoin("a", "b\u0000", 
"c");
+               assertNotEquals(t1, t2,
+                       "length-prefixed outer AAD composition must not let a 
NUL-bearing aad/keyId/identity field collide two distinct triples");
+       }
+
+       @Test void b09_lengthPrefixJoin_matchesExpectedEncodingForThreeFields() 
{
+               // Pins the exact wire shape: len(f0) NUL f0 NUL len(f1) NUL f1 
NUL len(f2) NUL f2.
+               assertEquals("2\u0000ab\u00000\u0000\u00001\u0000c", 
AeadRequestStateCodec.lengthPrefixJoin("ab", "", "c"));
        }
 }
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
index d7c0dda92e..6dfab65649 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
@@ -61,6 +61,15 @@ class McpResourceServerBinding_Test extends TestBase {
         */
        private static final TokenValidator VALIDATOR = token -> switch (token) 
{
                case "good" -> new ClaimsPrincipal("alice", Map.of("aud", 
RESOURCE, "scope", "mcp.read mcp.write"));
+               // TODO-325 cross-identity fixture: a DIFFERENT authenticated 
principal (bob) with the same audience + baseline
+               // scope, so it clears the gate but binds a different 
requestState identity than 'good' (alice).
+               case "good2" -> new ClaimsPrincipal("bob", Map.of("aud", 
RESOURCE, "scope", "mcp.read mcp.write"));
+               // TODO-325 real iss|sub claim fixtures: 'good'/'good2' above 
carry no iss/sub claims, so the requestState
+               // binding they exercise end-to-end is only the getName() 
fallback. 'good3'/'good4' carry the SAME subject
+               // (alice) but a DIFFERENT issuer, so they exercise the actual, 
primary iss|sub claim-read path (settled
+               // decision 1) end-to-end, not the fallback.
+               case "good3" -> new ClaimsPrincipal("alice", Map.of("aud", 
RESOURCE, "scope", "mcp.read mcp.write", "iss", "https://idp-a";, "sub", 
"alice"));
+               case "good4" -> new ClaimsPrincipal("alice", Map.of("aud", 
RESOURCE, "scope", "mcp.read mcp.write", "iss", "https://idp-b";, "sub", 
"alice"));
                case "noscope" -> new ClaimsPrincipal("alice", Map.of("aud", 
RESOURCE, "scope", "other"));
                case "wrongaud" -> new ClaimsPrincipal("alice", Map.of("aud", 
"http://evil.example.com";, "scope", "mcp.read"));
                // SEP-2350 step-up fixtures: baseline mcp.read + the 
per-operation tools.exec (exact and hierarchical grants).
@@ -418,6 +427,62 @@ class McpResourceServerBinding_Test extends TestBase {
                assertEquals("alice", J_CODEC.unsealPrincipal.get());  // the 
authenticated principal reached unseal
        }
 
+       // TODO-325: a requestState sealed under one authenticated identity 
(alice, bearer 'good') cannot be resumed under
+       // a DIFFERENT authenticated identity (bob, bearer 'good2'); the 
principal is folded into the AAD, so unseal fails
+       // GCM tag verification and surfaces as a JSON-RPC -32602 
invalid-params error rather than re-invoking the handler.
+       @Test void j02_crossIdentityResumeIsRejected() throws Exception {
+               // Round 1: pause -> seal under alice.
+               var pauseJson = clientJ().post("/").contentString(bodyElicit(1, 
"tools/call", JsonMap.of("name", "ask")))
+                       .header("Mcp-Method", "tools/call").header("Mcp-Name", 
"ask")
+                       .header("Authorization", "Bearer good")
+                       .run().assertStatus(200).getContent().asString();
+               var token = 
org.apache.juneau.marshall.marshaller.Json.to(pauseJson, 
JsonMap.class).getMap("result").getString("requestState");
+               assertNotNull(token);
+
+               // Round 2: resume the SAME token under a different bearer -> 
different principal (bob) -> unseal rejects it.
+               var resumeParams = JsonMap.of("name", "ask", "requestState", 
token, "inputResponses", JsonMap.of("q1", "answer"));
+               var resumeJson = 
clientJ().post("/").contentString(bodyElicit(2, "tools/call", resumeParams))
+                       .header("Mcp-Method", "tools/call").header("Mcp-Name", 
"ask")
+                       .header("Authorization", "Bearer good2")
+                       .run().assertStatus(200).getContent().asString();
+               var resp = 
org.apache.juneau.marshall.marshaller.Json.to(resumeJson, JsonMap.class);
+               assertNull(resp.get("result"), "a cross-identity resume must 
not produce a successful result");
+               assertEquals(-32602, resp.getMap("error").getInt("code"), "a 
cross-identity resume must be rejected as invalid params");
+       }
+
+       // TODO-325: end-to-end proof of the PRIMARY iss|sub claim-bound path 
(not the getName() fallback j01/j02
+       // exercise). 'good3' (iss-a|alice) and 'good4' (iss-b|alice) are real 
ClaimsPrincipal fixtures carrying iss/sub
+       // claims: same subject, different issuer. A requestState sealed under 
good3 must not be resumable under good4
+       // (cross-IdP), proving the codec actually reads and binds iss|sub over 
real HTTP dispatch, and the SAME token
+       // still round-trips when resumed under the original issuer/subject.
+       @Test void j03_crossIssuerResumeIsRejected() throws Exception {
+               // Round 1: pause -> seal under good3 (iss-a|alice).
+               var pauseJson = clientJ().post("/").contentString(bodyElicit(1, 
"tools/call", JsonMap.of("name", "ask")))
+                       .header("Mcp-Method", "tools/call").header("Mcp-Name", 
"ask")
+                       .header("Authorization", "Bearer good3")
+                       .run().assertStatus(200).getContent().asString();
+               var token = 
org.apache.juneau.marshall.marshaller.Json.to(pauseJson, 
JsonMap.class).getMap("result").getString("requestState");
+               assertNotNull(token);
+
+               // Round 2: resume the SAME token under good4 -> same sub 
(alice) but a DIFFERENT iss (idp-b) -> a different
+               // bound identity -> unseal rejects it (GCM tag mismatch 
surfaced as -32602 invalid params).
+               var resumeParams = JsonMap.of("name", "ask", "requestState", 
token, "inputResponses", JsonMap.of("q1", "answer"));
+               var resumeJson = 
clientJ().post("/").contentString(bodyElicit(2, "tools/call", resumeParams))
+                       .header("Mcp-Method", "tools/call").header("Mcp-Name", 
"ask")
+                       .header("Authorization", "Bearer good4")
+                       .run().assertStatus(200).getContent().asString();
+               var resp = 
org.apache.juneau.marshall.marshaller.Json.to(resumeJson, JsonMap.class);
+               assertNull(resp.get("result"), "a cross-issuer resume must not 
produce a successful result");
+               assertEquals(-32602, resp.getMap("error").getInt("code"), "a 
cross-issuer resume must be rejected as invalid params");
+
+               // Round 3: the SAME token resumed under the ORIGINAL 
issuer/subject (good3) still round-trips cleanly -
+               // the rejection above is specific to the mismatched iss, not a 
side effect on the token itself.
+               clientJ().post("/").contentString(bodyElicit(3, "tools/call", 
resumeParams))
+                       .header("Mcp-Method", "tools/call").header("Mcp-Name", 
"ask")
+                       .header("Authorization", "Bearer good3")
+                       .run().assertStatus(200);
+       }
+
        // 
---------------------------------------------------------------------------------------------
        // Servlet path with RS auth DISABLED (default) - behavior unchanged.
        // 
---------------------------------------------------------------------------------------------

Reply via email to