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)}
+ * — 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 — 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)} —
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↔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 — {@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 — 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 — 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 — 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 — 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
— fail-closed with a fixed sentinel): a
+ * <jk>null</jk> principal binds to a constant sentinel identity rather
than "skip binding", so anonymous↔
+ * anonymous round-trips while anonymous↔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 — 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.
//
---------------------------------------------------------------------------------------------