From 13a48f6075f989dcf69643a557133e432f40d98a Mon Sep 17 00:00:00 2001 From: Minekube AI Engineer <1535738075139801220+minekube-ai@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:25:58 +0000 Subject: [PATCH] fix(core): admit org-less endpoints under Bedrock identity enforcement An endpoint without an organization parent is a first-class endpoint-scoped identity scope: the envelope binds to the endpoint itself and the org dimension is optional (moxy #544, support case: gurtville scope_unavailable). - BedrockIdentityVerifier no longer requires a non-empty org_id in the envelope endpoint scope, and treats an empty configured org as no org constraint (optionalNullOrEmpty) instead of throwing. - BedrockIdentityEnforcer only requires the endpoint id for scope completeness; a missing org no longer rejects under require-mode enforcement. - TDD: org-less envelope verify + require-mode enforcer tests failed on the unfixed code (orgId is required / allowed=false) and pass after. --- .../bedrock/BedrockIdentityVerifier.java | 9 ++-- .../bedrock/BedrockIdentityEnforcer.java | 5 ++- .../bedrock/BedrockIdentityVerifierTest.java | 24 ++++++++++ .../bedrock/BedrockIdentityEnforcerTest.java | 44 ++++++++++++++++++- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java index 865fdbcb3..3e3da2b3d 100644 --- a/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java +++ b/api/src/main/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifier.java @@ -65,7 +65,7 @@ private BedrockIdentityVerifier(Builder builder) { this.now = builder.now; this.endpointId = optionalNonEmpty(builder.endpointId, "endpointId"); this.endpointName = requireNonEmpty(builder.endpointName, "endpointName"); - this.orgId = optionalNonEmpty(builder.orgId, "orgId"); + this.orgId = optionalNullOrEmpty(builder.orgId); this.sessionId = requireNonEmpty(builder.sessionId, "sessionId"); this.protocol = requireNonEmpty(builder.protocol, "protocol"); this.bedrockAuthPolicy = builder.bedrockAuthPolicy; @@ -277,8 +277,7 @@ private static void validate(Envelope envelope) throws BedrockIdentityVerificati } if (isEmpty(envelope.issuer) || envelope.endpoint == null || isEmpty(envelope.endpoint.id) || - isEmpty(envelope.endpoint.name) || - isEmpty(envelope.endpoint.org_id)) { + isEmpty(envelope.endpoint.name)) { throw new BedrockIdentityVerificationException("identity envelope endpoint scope is incomplete"); } if (envelope.session == null || @@ -402,6 +401,10 @@ private static String optionalNonEmpty(String value, String name) { return requireNonEmpty(value, name); } + private static String optionalNullOrEmpty(String value) { + return value == null || value.isEmpty() ? null : value; + } + private static boolean isEmpty(String value) { return value == null || value.isEmpty(); } diff --git a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java index e9face2cb..2aabdf9ef 100644 --- a/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java +++ b/core/src/main/java/com/minekube/connect/bedrock/BedrockIdentityEnforcer.java @@ -239,7 +239,10 @@ Decision verify( return Decision.allowed(null); } - if (hasEnvelope && (!hasScopeValue(endpointId) || !hasScopeValue(endpointOrgId))) { + // The endpoint itself is the identity binding; the org dimension is + // optional (an org-less endpoint is a first-class endpoint-scoped + // scope). Only a missing endpoint id makes the scope incomplete. + if (hasEnvelope && !hasScopeValue(endpointId)) { return rejectOrWarn(player, mode, "authenticated endpoint scope is incomplete"); } if (!identityConfiguration.isUsable()) { diff --git a/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java b/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java index b148b992d..5c2fd7e2e 100644 --- a/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java +++ b/core/src/test/java/com/minekube/connect/api/player/bedrock/BedrockIdentityVerifierTest.java @@ -49,6 +49,30 @@ void verifiesEndpointScopedBedrockXuidEnvelopeFromGameProfileProperty() throws E assertEquals("endpoint-id", claims.getEndpointId()); } + @Test + void verifiesEndpointScopedBedrockXuidEnvelopeWithoutOrg() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", "endpoint-id", "endpoint", ""); + GameProfile profile = profileWithEnvelope(envelope); + + BedrockIdentityVerifier verifier = BedrockIdentityVerifier.builder() + .publicKey(rawEd25519PublicKey(keyPair)) + .now(NOW) + .endpointId("endpoint-id") + .endpointName("endpoint") + .orgId("") + .sessionId("session-1") + .protocol("bedrock") + .replayCache(new BedrockIdentityReplayCache()) + .build(); + + BedrockIdentityClaims claims = verifier.verify(profile); + + assertEquals("bedrock_xuid", claims.getPrincipalType()); + assertEquals("endpoint-id", claims.getEndpointId()); + assertEquals("", claims.getOrgId()); + } + @Test void rejectsSignedEnvelopeWithQuotedVersion() throws Exception { KeyPair keyPair = ed25519KeyPair(); diff --git a/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java index eeb1fb9e6..c4b20657a 100644 --- a/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java +++ b/core/src/test/java/com/minekube/connect/bedrock/BedrockIdentityEnforcerTest.java @@ -618,6 +618,27 @@ void requireModeRejectsReplay() throws Exception { assertTrue(replay.message().contains("Bedrock identity verification failed")); } + @Test + void requireModeAllowsOrglessEndpointScope() throws Exception { + KeyPair keyPair = ed25519KeyPair(); + ConnectConfig config = config( + "require", + base64(keyPair.getPublic().getEncoded()), + "trusted_bedrock_xuid"); + String envelope = signedEnvelope(keyPair, VALID_NONCE, "session-1", config.getEndpoint(), "minekube-connect-test", ""); + BedrockIdentityEnforcer enforcer = new BedrockIdentityEnforcer(config, mock(ConnectLogger.class), () -> NOW); + ConnectPlayer player = player("session-1", profileWithEnvelope(envelope)); + + // An org-less endpoint is a first-class endpoint-scoped identity scope: + // the default trusted_bedrock_xuid policy admits the player even under + // strict require-mode enforcement (support case: gurtville). + BedrockIdentityEnforcer.Decision decision = enforcer.verify(player, "endpoint-id", ""); + + assertTrue(decision.allowed()); + assertNotNull(decision.verifiedClaims()); + assertEquals("", decision.verifiedClaims().getOrgId()); + } + private static ConnectPlayer player(String sessionId, GameProfile profile) { return new ConnectPlayerImpl(sessionId, profile, new Auth(false), ""); } @@ -701,13 +722,34 @@ private static String signedEnvelope( String endpointName, String issuer, Instant issuedAt) throws Exception { + return signedEnvelope(keyPair, nonce, sessionId, endpointName, issuer, issuedAt, "org-id"); + } + + private static String signedEnvelope( + KeyPair keyPair, + String nonce, + String sessionId, + String endpointName, + String issuer, + String orgId) throws Exception { + return signedEnvelope(keyPair, nonce, sessionId, endpointName, issuer, NOW, orgId); + } + + private static String signedEnvelope( + KeyPair keyPair, + String nonce, + String sessionId, + String endpointName, + String issuer, + Instant issuedAt, + String orgId) throws Exception { Envelope envelope = new Envelope(); envelope.version = 1; envelope.issuer = issuer; envelope.endpoint = new Endpoint(); envelope.endpoint.id = "endpoint-id"; envelope.endpoint.name = endpointName; - envelope.endpoint.org_id = "org-id"; + envelope.endpoint.org_id = orgId; envelope.session = new Session(); envelope.session.id = sessionId; envelope.session.protocol = "bedrock";