Skip to content

Add Minecraft Education Edition support#6251

Closed
SendableMetatype wants to merge 6 commits intoGeyserMC:masterfrom
SendableMetatype:pr-clean
Closed

Add Minecraft Education Edition support#6251
SendableMetatype wants to merge 6 commits intoGeyserMC:masterfrom
SendableMetatype:pr-clean

Conversation

@SendableMetatype
Copy link
Copy Markdown

@SendableMetatype SendableMetatype commented Mar 23, 2026

Demo: Education + Bedrock + Java cross-play

Edit: Updated some things to reflect the current state for anyone referencing this in the future

Background

There is no currently working tool to connect Education clients to Java servers. The last one, bundabrg's GeyserReversion, died in July 2022 when the extension interface it depended on was not accepted upstream. Since then, there have been attempts to revive it but none were successful.

Back in 2020, Education ran a significantly older protocol than Bedrock (v363 vs v408) and needed a full translation layer with separate codecs, custom serializers for StartGame, InventoryTransaction, CraftingData, Event, and PlayerList packets, and its own block/item mappings submodule. That's why bundabrg's PR #536 required 76 commits and a dependency injection system to support multiple editions.

This PR is the result of investigating what it would actually take with the current state of Education Edition.

Step 1: Does Education still need protocol translation?

No. This is the key discovery that makes everything else possible.

Education Edition 1.21.132 (the "Copper, Collaboration & Compete" update, released February 17, 2026) runs on Bedrock protocol 898. This is the same protocol as standard Bedrock 1.21.132 (the "Mounts of Mayhem" release, January 8, 2026). Both are built from the same source tree: Education's current branch is edu_r21_u13.

Verified by checking the client debug screen:

Education Stable:   v1.21.132  | Build 41141490 | Protocol 898 | Branch edu_r21_u13
Bedrock 26.3:       v26.3      | Build 41676402 | Protocol 924 | Branch r/26_u0

The build numbers (41.1M vs 41.7M) confirm they were compiled within a short window of each other. Protocol 898 maps to Bedrock_v898.java in CloudburstMC's Protocol library, which Geyser already supports as part of its 1.21.130-26.10 range.

Block and item palettes are also identical. Geyser's runtime_item_states.1_21_130.json already contains every Education entry: element_0 through element_118, compound, medicine, bleach, balloon, sparkler, glow_stick, ice_bomb, chemistry_table, compound_creator, lab_table, material_reducer, element_constructor, allow, deny, border_block, camera, chalkboard, colored_torch_*, underwater_torch. The block_palette.1_21_130.nbt includes the corresponding education block entries too. Education-specific content is gated behind the educationFeaturesEnabled runtime flag, not by different palettes.

This eliminates the entire translation layer bundabrg needed. No separate mappings, no version-specific codecs, no block ID conversion, no item remapping.

Step 2: What happens if you just point an Education client at Geyser?

Two things go wrong, and they're independent problems.

Problem 1: Auth rejection

Education doesn't use Xbox Live. It authenticates through Microsoft 365 / Entra ID (the identity system used by schools and organizations). The client sends a CertificateChainPayload - the same payload type as standard Bedrock, so you can't distinguish them by auth type alone. But the login chain is not signed by Mojang's root key. result.signed() returns false, and Geyser rejects the connection.

Problem 2: Silent disconnect

Even after disabling login validation (validate-bedrock-login: false), the client connects, receives StartGamePacket, and silently disconnects 1-1.5 seconds later. No error message on the client. No disconnect reason in the logs beyond a generic connection close. The client briefly shows the loading screen and then returns to the menu.

Step 3: Solving the auth

Two fixes for two problems.

Fix for auth rejection: Education clients are detected via the IsEduMode field in the client data JWT before the Xbox validation check runs. When detected, the result.signed() check is bypassed. Instead, education chains are validated through their own tenant-based path instead.

Fix for the handshake: Education clients expect a signedToken claim in the ServerToClientHandshakePacket JWT. This is a tenant-scoped authorization token in the format:

tenantId|serverId|expiry|signature

Example: 03b5e7a1-cb09-4417-9e1a-c686c440b2c5|e2a79ff3-29ba-4cc2-99ad-4c355fe81bfa|2026-03-19T14:18:13.486Z|41863f21cdbeacbd1...

Without it, the client shows "Invalid Tenant ID." The token is obtained either directly via the discovery /host endpoint (discovery.minecrafteduservices.com/host) by exchanging an OAuth2 bearer token, or through Microsoft's dedicated server API (dedicatedserver.minecrafteduservices.com) which handles full server registration, hosting, and token management through Minecraft Education's server infrastructure (MESS - Minecraft Education Server Services). Tokens expire after approximately 10 days based on the embedded timestamp, and can be refreshed automatically.

The fix in LoginEncryptionUtils.startEncryptionHandshake() constructs a custom JWS when an Education client is detected:

if (session.isEducationClient() && educationToken != null) {
    JsonWebSignature jws = new JsonWebSignature();
    jws.setAlgorithmHeaderValue("ES384");
    jws.setHeader("x5u", Base64.getEncoder().encodeToString(
        serverKeyPair.getPublic().getEncoded()));
    jws.setKey(serverKeyPair.getPrivate());

    JwtClaims claims = new JwtClaims();
    claims.setClaim("salt", Base64.getEncoder().encodeToString(token));
    claims.setClaim("signedToken", educationToken);
    jws.setPayload(claims.toJson());
    jwt = jws.getCompactSerialization();
}

The algorithm (ES384), key format, and salt claim are identical to the standard Bedrock handshake, only the signedToken claim is added.

Multiple tenants are supported. Each connecting student's tenant ID is matched against the configured tokens, and clients are verified via MESS nonce verification. This allows a single server to accept Education players from different schools.

Floodgate change

This PR extends BedrockData from 12 to 15 fields to carry education identity (education flag, tenant ID, AD role). A corresponding Floodgate PR handles the receiving side: stable UUID generation from tenant-scoped identity (education players don't have XUIDs) and username formatting. This is the simplest approach since Geyser has no way to negotiate field count with Floodgate. It could also be done defensively by only appending the extra fields for education sessions, but that adds branching for no practical benefit.

Authentication methods

Tokens can be obtained three ways: full MESS registration (recommended for schools, requires Global Admin, fully automated token refresh, server appears in the built-in server list), device code flow via the /host endpoint (any teacher, automatic refresh, works unless the tenant blocks device code flow), or the token tool (MSAL browser login, fallback for tenants with strict conditional access, manual refresh every ~10 days).

Step 4: Finding the disconnect - the investigation

This was the hard part and took the majority of the development time. The client disconnects silently with disconnect code 0x29 (generic "Disconnected"), giving no indication of what went wrong.

Systematic elimination

I worked through every plausible theory:

Theory Result Method
Protocol translation needed Confirmed protocol 898 match, Geyser supports natively
Block/item palette differences Compared palette files, identical including Education entries
Education flags in StartGamePacket Toggled eduEditionOffers, eduFeaturesEnabled, educationProductionId on/off, no change
Specific packets after StartGamePacket Suppressed each packet type individually, no change
Education packet codec crashes ❌→Fixed Changed ILLEGAL_SERIALIZER to IGNORED_SERIALIZER for 7 packet types, fixed crashes but disconnect persisted
Missing EducationSettingsPacket Sent with various field combinations, no change

And 13 other theories including experiments, entity packets, movement mode, skin validation, safety service phone-home, vanillaVersion, gamerules, currentTick, and token format. All ruled out.

Nuclear suppression test

The breakthrough diagnostic: I suppressed ALL packets after StartGamePacket - every single one. No entity spawns, no chunks, no commands, nothing. The client still disconnected at the exact same timing (1-1.5 seconds).

This definitively proved:

  1. The problem is in or before StartGamePacket (not any subsequent packet)
  2. No post-StartGame packet suppression or modification could fix it
  3. The problem is structural (the packet itself is malformed), not value-based

DLL injection and binary analysis

To understand what the client was doing internally, I injected a custom DLL into the Education Edition process (a UWP/Centennial app, requiring special injection techniques).

closesocket hook: Hooked closesocket in ws2_32.dll to intercept the moment the client decides to disconnect. Results:

  • Disconnect reason was 0x29 (41) - generic "Disconnected" from the DisconnectFailReason enum (129 total values)
  • A flag at [this->field_0xD0->field_0x40] was ALREADY set on the very first tick check - meaning it was set during connection initialization, not during gameplay
  • persona_right_leg string appeared nearby in memory, which was a red herring (coincidental memory layout from the persona skin system)
  • Patching out the disconnect check at that flag did NOT prevent the disconnect - another disconnect path existed elsewhere

Key RVA offsets identified (base varies per launch due to ASLR):

RVA Purpose
0x28AEFA0 Disconnect function - hardcodes reason 0x29
0x29E3BA0 Flag checker - reads field_0xD0->field_0x40, triggers disconnect if non-zero
0x29E3C11 Disconnect branch - cmp byte ptr [rax], bpl; je skip
0x02935E40 DisconnectFailReason enum registration
0xADF9C0 Outer tick function with status check at [rbx + 0x414]

The DLL analysis was consistent with the actual root cause: a deserialization failure in StartGamePacket processing sets a disconnect flag immediately during connection initialization, before any gameplay packets are processed. The flag being set on the first tick check rather than at any specific game event pointed squarely at the packet parsing stage.

Finding the 3 missing fields

Running strings on the Education dedicated server binary (bedrock_server_edu.exe) revealed three field names not present in the standard Bedrock binary or in any public protocol documentation:

  • educationReferrerId
  • educationCreatorWorldId
  • educationCreatorId

Cross-referencing with CloudburstMC's StartGameSerializer_v898.java and walking up through its parent class hierarchy (StartGameSerializer_v291.javawriteLevelSettings()), I confirmed these fields are written as strings at the end of LevelSettings in the serialization order. The standard Bedrock serializer doesn't include them. The Education client expects them and reads past the end of the buffer when they're absent, causing a deserialization failure.

No public documentation exists for these fields - not in Mojang's bedrock-protocol-docs, not in CloudburstMC's Protocol source, not in PrismarineJS, not on the Bedrock Wiki. This is the piece that blocked KrystilizeNevaDies/GeyserEE and would block anyone else attempting this without access to the Education binary.

Step 5: The fix

A custom EducationStartGameSerializer extends StartGameSerializer_v898 and overrides writeLevelSettings() to call super.writeLevelSettings() then append three empty strings:

@Override
protected void writeLevelSettings(ByteBuf buffer, BedrockCodecHelper helper, StartGamePacket packet) {
    super.writeLevelSettings(buffer, helper, packet);
    helper.writeString(buffer, ""); // educationReferrerId
    helper.writeString(buffer, ""); // educationCreatorWorldId
    helper.writeString(buffer, ""); // educationCreatorId
}

This serializer is registered in the codec for Education sessions only. Standard Bedrock sessions continue using the unmodified serializer.

The client connects, stays connected, and gameplay works normally.

Complete summary of changes

Education support is disabled by default (tenancy-mode: off). Existing Geyser servers are completely unaffected. When enabled, all changes are gated behind tenancy-mode != off at startup and session.isEducationClient() at runtime, so the normal Bedrock pipeline is untouched. MESS registration settings and standalone tokens are stored in separate files (edu_official.yml and edu_standalone.yml) rather than in config.yml:

Core changes (required for connection):

  1. EducationStartGameSerializer (new file) - extends StartGameSerializer_v898, appends 3 empty strings after super.writeLevelSettings(). This is the root cause fix.

  2. LoginEncryptionUtils.java - injects signedToken claim into the handshake JWT when IsEduMode is detected in the client data JWT. Rejects education clients when tenancy mode is off. Uses the same ES384 algorithm and key format as standard Bedrock.

  3. GeyserSession.java - adds per-session educationClient boolean flag. When true, sets Education-specific StartGamePacket field gamerule: "codebuilder:false". Prevents client from sending packets with ILLEGAL serializers.

  4. BedrockClientData.java - adds @SerializedName("IsEduMode") boolean isEduMode field with accessor, enabling per-session Education client detection from the client data JWT.

Authentication and server management:

  1. EducationAuthManager.java (new file) - handles MESS registration, OAuth device code flow, token routing by tenant ID, nonce verification, token refresh, session persistence. Well-sectioned into 18 labeled regions.

  2. EducationChainVerifier.java (new file) - extracts and verifies tenant identity from the Education login chain JWT.

  3. EducationTenancyMode.java (new file) - enum with OFF (default), OFFICIAL, HYBRID, STANDALONE.

Supporting changes:

  1. GeyserImpl.java - initializes education auth manager gated behind tenancy-mode != off. Loads manual and device-code tokens based on mode.

  2. GeyserConfig.java - adds EducationConfig interface with tenancyMode() defaulting to OFF.

  3. EduCommand.java (new file) - /geyser edu command with status (tenant table, mode-appropriate info), players, token, and reset subcommands.

  4. BedrockData.java (Floodgate common) - extends from 12 to 15 fields to carry education flag, tenant ID, and AD role.

  5. GeyserSessionAdapter.java, UpstreamPacketHandler.java, SessionManager.java - education-specific handling for session lifecycle and XUID tracking.

  6. ConfigMigrations.java, Constants.java, CommandRegistry.java - config version bump to 8, edu command registration.

  7. Tests - EducationAuthManagerTest.java, EducationChainVerifierTest.java, EducationUuidTest.java (479 lines total).

What is NOT changed:

  • No modifications to block/item translation
  • No modifications to entity handling
  • No modifications to chunk encoding
  • No modifications to inventory handling
  • No new dependencies
  • Standard Bedrock clients are completely unaffected

Why this can't be an extension

I tried the extension approach first and documented exactly where it fails. The required changes operate below what the Extension API (v2.9.5) exposes:

  • Codec/serializer swap: The 3 extra strings must be written by the serializer in the Netty pipeline. The API operates above the codec layer - even if you intercept and modify the StartGamePacket object, the standard serializer still writes it without those fields. The Education client reads a binary stream, not object fields, and the bytes must be present at the right position.

  • Handshake JWT: The signedToken must be injected during startEncryptionHandshake(), before the session is fully established. No extension hook fires at this point in the connection lifecycle.

  • Login validation: Education chains aren't signed by Mojang's root key. The validation logic in the login path needs to accept Education clients through a separate path rather than globally disabling validation for everyone.

  • Per-session codec swapping: Extensions cannot swap codecs based on client type detected during login. The codec must be set before StartGamePacket is serialized but after the client type is known.

I also analyzed PR #5685 (Redned's upcoming Networking API). It can intercept and modify packets including during login, which is more capable than I initially expected. However, every send path in GeyserNetwork.send() round-trips through the session codec serializer. Even if you construct a modified packet, the standard serializer re-encodes it without the Education fields. The codec swap remains a hard blocker. Auth hooks also fire after LoginEncryptionUtils has already run.

Maintenance burden

Education never leads Bedrock. Every Education release catches up to a Bedrock protocol version that Geyser already supports, typically by 5-6 weeks:

  • Edu 1.21.03 (Jul 23, 2024) caught up to Bedrock 1.21.0 (Jun 13, 2024) - 6 weeks behind
  • Edu 1.21.91 (Jul 22, 2025) caught up to Bedrock 1.21.90 (Jun 17, 2025) - 5 weeks behind
  • Edu 1.21.132 (Feb 17, 2026) caught up to Bedrock 1.21.132 (Jan 8, 2026) - 6 weeks behind

This means that by the time an Education update ships, Geyser has already been handling that protocol for weeks. Maintenance cost for education support is effectively zero unless the 3 LevelSettings strings change, which they haven't since they were first added.

The per-session codec architecture already supports version divergence - if Education falls behind in the future, Education sessions use their codec while Bedrock sessions use the current one.

Bedrock 26.x vs Education 1.21.132: the version gap currently looks bigger than it is. 26.x is mostly just 1.21 with the new year-based versioning system. For practical purposes, Education and Bedrock players have identical survival experiences.

Prior art

  • bundabrg's PR #536 (May-Aug 2020) - 76 commits, required full protocol translation layer, dependency injection, separate mappings. Closed by author to pursue plugin approach.
  • bundabrg's GeyserReversion - Geyser extension for multi-version + Education support. Ran Education on a separate port (19133). Required custom Geyser builds with plugin support. Abandoned July 2022 because the extension interface was not accepted upstream.
  • KrystilizeNevaDies/GeyserEE (May 2022) - Ported bundabrg's TokenManager to newer Geyser. Had auth code (OAuth2 flow, tokens.json, multi-tenant) but no EducationStartGameSerializer. Would have hit the same silent disconnect.
  • Issue #2646 (Nov 2021) - User requested Education support. Camotoy closed with "not something the core team is interested in at this time. None of us have Education Edition on hand."
  • Multiple Minecraft Forum posts from educators requesting someone update GeyserReversion, including a non-profit creating worlds about literature, culture, and sustainability.

What has changed since 2021:

  • Education and Bedrock protocols have converged (898 vs bundabrg's 363→408 gap)
  • Official dedicated servers launched (October 2025)
  • GeyserReversion died with no replacement
  • The required changes are now 6 commits touching a handful of files, not 76 commits with a DI system

This PR is the first working Education-to-Java bridge since GeyserReversion died nearly four years ago.

How Education clients connect

There are three ways Education clients can connect to a Geyser server:

  1. MESS API registration - The official dedicated server system (launched October 2025). A Global Admin registers the server through the admin portal, and it appears in Education Edition's built-in server list. Microsoft explicitly encourages 3rd party tooling here.
  2. Resource pack - A resource pack can re-add the standard Bedrock server list UI to Education Edition by injecting a button that triggers the built-in button.menu_servers action. The server list screen and its code paths are still present in the Education client - Microsoft only removed the UI element that opens it.
  3. minecraftedu://connect/?serverUrl=ip:port - URI scheme, works on all platforms (Windows, macOS, iPad, Chromebook, Android). A simple HTML page with a link is the easiest distribution method.

Education Edition removed the direct IP connect UI in the 1.18.32 update (August 2022), replacing "Join by IP Address" with "Join by Connection Id" when they moved to WebRTC-based multiplayer through Microsoft's relay infrastructure. This wasn't a deliberate restriction but a UX change because there was simply nothing to type into an IP field anymore since all connections went through Microsoft's signalling servers back then. The underlying direct connect code remains fully functional in the Bedrock engine.

Testing

Tested with Education Edition 1.21.132 and Preview (which is confusingly still on 1.21.131.2) on Windows, iOS, Android. Confirmed working with both the token tool and device code authentication methods. Multiple independent users have successfully connected on different hosting setups (Paper standalone, Velocity networks).

I have a commercial Education license and can provide test accounts to anyone who wants to verify without dealing with Microsoft licensing.

Documentation

Full technical documentation (3,300+ lines) is available in the repository, covering the complete protocol analysis, authentication system, debugging history, connection methods, and deployment guide. The MESS API is publicly documented by Microsoft with a sample tooling notebook and Microsoft actively encouraging third-party implementations. The 3 Education-specific StartGamePacket fields (educationReferrerId, educationCreatorWorldId, educationCreatorId) are not documented in any public protocol specification and were identified through analysis of the Education dedicated server binary.

  • Master Documentation - Complete technical reference: protocol analysis, authentication flow, MESS API, debugging history
  • Setup Guide - Installation and configuration
  • MESS Tooling Reference - Microsoft Education Server Services API reference
  • Token Tool - Standalone token acquisition tool for any tenant environment

Legal

This PR does not violate any Microsoft terms. The Minecraft EULA explicitly excludes Education Edition from its scope. The Education EULA governs the client software only and does not address servers, mods, external connections, or interoperability. No client software is modified. The MESS API is used exactly as Microsoft designed and documented it. EduGeyser operates in the same legal space as Geyser itself: server-side protocol translation.

The protocol analysis work (identifying the 3 undocumented StartGamePacket fields from the Education dedicated server binary) is protected under Article 21 of the Swiss Federal Act on Copyright, which permits decompilation for the purpose of interoperability with independently created software. The Education EULA's own reverse engineering clause includes the standard exception: "except and only to the extent that applicable law expressly permits."

A detailed legal analysis covering all relevant Microsoft documents is available here.

- Add education config subsection with tenancy-mode field
  (OFF/OFFICIAL/HYBRID/STANDALONE, default OFF)
- Add EducationTenancyMode enum for compile-time safety
- Add education fields to BedrockClientData: EduTokenChain,
  EduJoinerToHostNonce, EduSessionToken, IsEduMode, AdRole
- Add education fields to BedrockData for Floodgate handshake
- Add config version 8 migration
- Adjust ConfigLoaderTest for education section defaults
- Add EducationAuthManager: OAuth 2.0 device code flow, MESS server
  registration and hosting, periodic heartbeats, token refresh,
  tenant token pool with config-trust tracking, session persistence
- Add EducationChainVerifier: extracts tenant ID from EduTokenChain
  JWT, decodes and dumps chain data for debug logging, padBase64
  utility for base64url-to-base64 conversion
- Generate edu_official.yml and edu_standalone.yml on startup with
  comments and paste slots; template methods prevent comment drift
- Add nonce cache with on-demand MESS fetch_joiner_info verification
  (3-second timeout, 30-minute accumulating cache)
- Add standalone token acquisition via /geyser edu token command:
  device code flow, auto-refresh, re-auth on failure
- Support multiple tokens per tenant with newest-first routing
- Warn on startup for tokens expiring within 3 days
- Education init gated on tenancy-mode != OFF
- Initialize and shut down auth manager from GeyserImpl lifecycle
- Add EducationCodecProcessor: wraps base codec with education-specific
  overrides, re-enabling education packets (chemistry, NPCs, photos,
  Code Builder) as IGNORED instead of ILLEGAL for edu sessions
- Add EducationStartGameSerializer: appends 3 edu-specific string
  fields (eduSharedUri resource/buttonName/linkUri) to StartGamePacket
- Mark education packets as ILLEGAL in base CodecProcessor so they
  disconnect non-education clients that send them
- Add education login path in LoginEncryptionUtils: tenant extraction,
  nonce verification (official/hybrid/standalone), education handshake
  JWT with signedToken claim, chain dump for debug logging
- Reject education clients with clear message when tenancy mode is OFF
- Add education session fields to GeyserSession: educationClient,
  educationTenantId, nonceVerified flags
- Swap to education codec and send EducationSettingsPacket for edu
  clients during session initialization
- Add education UUID generation in GeyserSessionAdapter using
  deterministic tenant+username hash with fixed MSB
- Add duplicate education session check in SessionManager
- Add edu-specific STOP_BREAK threshold in BlockBreakHandler
- Add EduCommand with status/players/token/reset subcommands
- Status shows mode-aware display: tenant table with token health,
  Server ID/IP for official/hybrid only, device-code token count
- Register edu command in CommandRegistry (permission: geyser.command.edu)
- Add EducationAuthManagerTest: tenant extraction, config trust,
  nonce cache, join counters, formatExpiry, isActive (17 tests)
- Add EducationChainVerifierTest: padBase64 edge cases (8 tests)
- Add EducationUuidTest: determinism, collision resistance,
  MSB validation, input sensitivity (12 tests)
Copilot AI review requested due to automatic review settings March 23, 2026 13:50
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class Minecraft Education Edition (MCEE) connectivity to Geyser by detecting Education clients during login, performing tenant-based authentication/verification, and applying an Education-specific Bedrock codec override (notably StartGame serialization) so Education clients remain connected and playable.

Changes:

  • Add Education tenancy/auth flow (MESS registration + standalone tokens), tenant routing, and /geyser edu management command.
  • Detect Education clients via client-data JWT, bypass Xbox chain signing checks for edu, inject signedToken into the encryption handshake, and perform tenant/nonce verification depending on tenancy mode.
  • Use an Education-specific codec/StartGame serializer and adjust session/game rules and Floodgate data to support edu identities (tenant + role) and stable UUIDs.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
core/src/test/java/org/geysermc/geyser/session/EducationUuidTest.java Tests deterministic UUID generation for Education identities.
core/src/test/java/org/geysermc/geyser/network/EducationChainVerifierTest.java Tests Base64URL padding helper used for JWT decoding.
core/src/test/java/org/geysermc/geyser/network/EducationAuthManagerTest.java Tests tenant extraction, trust separation, expiry formatting, and counters in auth manager.
core/src/test/java/org/geysermc/geyser/configuration/ConfigLoaderTest.java Adjusts migration comparison to ignore newly added education section for older configs.
core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java Education client detection, tenancy gating, nonce/config-trust verification, and edu handshake JWT with signedToken.
core/src/main/java/org/geysermc/geyser/session/cache/BlockBreakHandler.java Adds Education-specific block-break action handling (CONTINUE_BREAK/STOP_BREAK).
core/src/main/java/org/geysermc/geyser/session/auth/BedrockClientData.java Adds Education client-data fields (IsEduMode, tenant, AD role, nonce/token chain).
core/src/main/java/org/geysermc/geyser/session/SessionManager.java Adds duplicate-session detection for Education players based on tenant+username (not XUID).
core/src/main/java/org/geysermc/geyser/session/GeyserSessionAdapter.java Extends Floodgate payload with edu fields and generates stable UUIDs for edu Floodgate sessions.
core/src/main/java/org/geysermc/geyser/session/GeyserSession.java Stores edu session state; sets edu StartGame fields, experiments, gamerules; swaps codec and sends EducationSettingsPacket.
core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java Uses tenant+username duplicate checks for Education clients; keeps XUID checks for others.
core/src/main/java/org/geysermc/geyser/network/EducationTenancyMode.java Defines tenancy modes (OFF/OFFICIAL/HYBRID/STANDALONE).
core/src/main/java/org/geysermc/geyser/network/EducationStartGameSerializer.java Appends/consumes the three Education-only LevelSettings strings to fix silent disconnect.
core/src/main/java/org/geysermc/geyser/network/EducationCodecProcessor.java Builds edu codec wrapper: StartGame serializer override + ignores edu-only packets instead of disconnecting.
core/src/main/java/org/geysermc/geyser/network/EducationChainVerifier.java Adds debug utilities to dump Education JWT chains for troubleshooting.
core/src/main/java/org/geysermc/geyser/network/EducationAuthManager.java Implements MESS registration, token acquisition/refresh, tenant token pool, and nonce verification.
core/src/main/java/org/geysermc/geyser/network/CodecProcessor.java Clarifies comment that edu packets are ILLEGAL in base codec and re-enabled for edu sessions only.
core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java Adds education config section and tenancy-mode config binding.
core/src/main/java/org/geysermc/geyser/configuration/ConfigMigrations.java Bumps config version to introduce education section defaults.
core/src/main/java/org/geysermc/geyser/command/defaults/EduCommand.java Adds /geyser edu command for status/players/token/reset workflows.
core/src/main/java/org/geysermc/geyser/command/CommandRegistry.java Registers the new edu command.
core/src/main/java/org/geysermc/geyser/GeyserImpl.java Initializes Education auth manager and loads/registers tokens based on tenancy mode.
core/src/main/java/org/geysermc/geyser/Constants.java Bumps CONFIG_VERSION to 8.
common/src/main/java/org/geysermc/floodgate/util/BedrockData.java Extends Floodgate BedrockData payload from 12→15 fields to carry edu flag, tenant, and AD role.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +944 to +953
private void parseServerTokenJwt(String jwtResponse) throws IOException {
this.serverTokenJwt = jwtResponse.trim();
String[] parts = serverTokenJwt.split("\\.");
if (parts.length < 2) {
throw new IOException("Invalid JWT response (got " + parts.length + " parts, expected 3)");
}

String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);

JsonObject payload = JsonParser.parseString(payloadJson).getAsJsonObject();
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT payload segments are Base64URL and commonly omit padding; decoding parts[1] without padding will throw IllegalArgumentException for many valid JWTs. Consider using the existing Base64URL padding helper (e.g., EducationChainVerifier.padBase64) before decoding, so MESS registration/fetch_token can't fail due to missing padding.

Copilot uses AI. Check for mistakes.
}
return null;
}
String payloadJson = new String(Base64.getUrlDecoder().decode(EducationChainVerifier.padBase64(parts[1])));
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new String(byte[]) uses the platform default charset. Since these decoded JWT parts are UTF-8 JSON, this should specify StandardCharsets.UTF_8 to be deterministic across platforms.

Suggested change
String payloadJson = new String(Base64.getUrlDecoder().decode(EducationChainVerifier.padBase64(parts[1])));
String payloadJson = new String(
Base64.getUrlDecoder().decode(EducationChainVerifier.padBase64(parts[1])),
StandardCharsets.UTF_8
);

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +108
if (parts.length >= 2) {
String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0])));
String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1])));
logger.debug("[EduChainDump] Header: %s", header);
logger.debug("[EduChainDump] Payload: %s", payload);
} else {
logger.debug("[EduChainDump] Raw: %s", jwtToken);
}
}
} else if (authPayload instanceof TokenPayload tokenPayload) {
String token = tokenPayload.getToken();
logger.debug("[EduChainDump] Auth payload is TokenPayload (single token).");
String[] parts = token.split("\\.");
if (parts.length >= 2) {
String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0])));
String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1])));
logger.debug("[EduChainDump] Header: %s", header);
logger.debug("[EduChainDump] Payload: %s", payload);
}
} else {
logger.debug("[EduChainDump] Unknown auth payload type: %s", authPayload.getClass().getName());
}

// Also dump the client data JWT
if (clientDataJwt != null) {
String[] parts = clientDataJwt.split("\\.");
logger.debug("[EduChainDump] --- Client Data JWT (parts: %s) ---", parts.length);
if (parts.length >= 2) {
String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0])));
String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1])));
logger.debug("[EduChainDump] Header: %s", header);
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new String(...) is using the platform default charset when decoding JWT headers/payloads. These are UTF-8 JSON, so the charset should be specified explicitly (e.g., StandardCharsets.UTF_8) to avoid garbled output on non-UTF-8 default platforms.

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +91
* when a block finishes breaking. This threshold is a heuristic, currently unused
* since server-authoritative block breaking handles edu clients correctly without it.
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constant/Javadoc says this STOP_BREAK threshold heuristic is "currently unused", but it is now used in the STOP_BREAK handling path below. Please update the comment to reflect current behavior (or remove the constant if it truly shouldn't be active).

Suggested change
* when a block finishes breaking. This threshold is a heuristic, currently unused
* since server-authoritative block breaking handles edu clients correctly without it.
* when a block finishes breaking. This threshold is a heuristic used in the STOP_BREAK
* handling path to decide whether to treat the packet as a full block break.

Copilot uses AI. Check for mistakes.

/**
* Whether this client is Minecraft Education Edition.
* Detected from TitleId in client JWT, with fallback to education-token config.
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Javadoc says Education clients are detected from "TitleId in client JWT", but in this PR the flag is set from BedrockClientData#isEducationEdition() (via the IsEduMode claim). Please update the comment so future changes don’t rely on an incorrect detection mechanism.

Suggested change
* Detected from TitleId in client JWT, with fallback to education-token config.
* Determined from {@link BedrockClientData#isEducationEdition()}, which uses the IsEduMode claim in the client JWT.

Copilot uses AI. Check for mistakes.
Comment on lines +514 to +516
this.educationAuthManager = new EducationAuthManager();
this.educationAuthManager.setup(this);
if (tenancyMode != EducationTenancyMode.OFF) {
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EducationAuthManager#setup() generates/writes edu_official.yml and edu_standalone.yml. Right now this runs unconditionally on startup, even when tenancy-mode is OFF, which introduces new files/IO side effects for servers that aren’t using Education support. Consider only constructing/setting up the auth manager (and generating these files) when tenancy-mode != OFF, or lazily when the feature/command is first used.

Suggested change
this.educationAuthManager = new EducationAuthManager();
this.educationAuthManager.setup(this);
if (tenancyMode != EducationTenancyMode.OFF) {
if (tenancyMode != EducationTenancyMode.OFF) {
this.educationAuthManager = new EducationAuthManager();
this.educationAuthManager.setup(this);

Copilot uses AI. Check for mistakes.
Comment on lines 99 to 112
public static BedrockData fromString(String data) {
String[] split = data.split("\0");
if (split.length != EXPECTED_LENGTH) {
return emptyData(split.length);
}

LinkedPlayer linkedPlayer = LinkedPlayer.fromString(split[8]);
// The format is the same as the order of the fields in this class
return new BedrockData(
split[0], split[1], split[2], Integer.parseInt(split[3]), split[4],
Integer.parseInt(split[5]), Integer.parseInt(split[6]), split[7], linkedPlayer,
"1".equals(split[9]), Integer.parseInt(split[10]), split[11], split.length
"1".equals(split[9]), Integer.parseInt(split[10]), split[11],
"1".equals(split[12]), split[13], Integer.parseInt(split[14]), split.length
);
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bumping EXPECTED_LENGTH to 15 means any older Floodgate/Geyser counterpart still sending 12 fields will now parse as emptyData(...), which can cause confusing downstream failures. If backwards compatibility is desired, consider accepting both lengths (e.g., parse 12-field data and default the new education fields) and only treat truly malformed inputs as empty/invalid.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +115
public static void dumpEduChain(GeyserLogger logger, AuthPayload authPayload, String clientDataJwt) {
try {
logger.debug("[EduChainDump] ========== EDUCATION CLIENT JWT CHAIN DUMP ==========");

if (authPayload instanceof CertificateChainPayload certChain) {
List<String> chain = certChain.getChain();
logger.debug("[EduChainDump] Chain length: %s", chain.size());

for (int i = 0; i < chain.size(); i++) {
String jwtToken = chain.get(i);
String[] parts = jwtToken.split("\\.");
logger.debug("[EduChainDump] --- Chain JWT #%s (parts: %s) ---", i, parts.length);

if (parts.length >= 2) {
String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0])));
String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1])));
logger.debug("[EduChainDump] Header: %s", header);
logger.debug("[EduChainDump] Payload: %s", payload);
} else {
logger.debug("[EduChainDump] Raw: %s", jwtToken);
}
}
} else if (authPayload instanceof TokenPayload tokenPayload) {
String token = tokenPayload.getToken();
logger.debug("[EduChainDump] Auth payload is TokenPayload (single token).");
String[] parts = token.split("\\.");
if (parts.length >= 2) {
String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0])));
String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1])));
logger.debug("[EduChainDump] Header: %s", header);
logger.debug("[EduChainDump] Payload: %s", payload);
}
} else {
logger.debug("[EduChainDump] Unknown auth payload type: %s", authPayload.getClass().getName());
}

// Also dump the client data JWT
if (clientDataJwt != null) {
String[] parts = clientDataJwt.split("\\.");
logger.debug("[EduChainDump] --- Client Data JWT (parts: %s) ---", parts.length);
if (parts.length >= 2) {
String header = new String(Base64.getUrlDecoder().decode(padBase64(parts[0])));
String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1])));
logger.debug("[EduChainDump] Header: %s", header);
// Client data payloads can exceed 10KB (skin data); truncate to keep logs readable
if (payload.length() > 2000) {
logger.debug("[EduChainDump] Payload (truncated): %s...", payload.substring(0, 2000));
} else {
logger.debug("[EduChainDump] Payload: %s", payload);
}
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dumpEduChain logs fully decoded JWT headers/payloads. Even in debug mode, these payloads can contain sensitive identifiers (tenant IDs, user identity claims, potentially token-like values) and may end up in shared logs. Consider redacting sensitive fields (or only logging a minimal subset) to reduce accidental data exposure when debugMode is enabled.

Copilot uses AI. Check for mistakes.
- Specify UTF-8 charset in all new String(Base64...) calls in
  EducationChainVerifier (6 occurrences) and EducationAuthManager (1)
- Fix GeyserSession.educationClient Javadoc to reflect actual
  detection via BedrockClientData.isEducationEdition()
@onebeastchris
Copy link
Copy Markdown
Member

Hello! Thank you for the contribution. However, after an internal discussion, we don't see us being able to merge this PR due to multiple reasons:

  • This is a very niche feature, which the majority of users won't use. We've received very few requests for it over the years - unlike e.g. split screen support. Therefore, such a feature would be best within a fork or Geyser extension - we're aware about the currently limited API, but are working towards improving it.
  • None of us devs currently have access to education edition, nor are we able to maintain another version. Because of this, we would fully rely on other people to test features, and would be unable to look into any edu-specific bugs. That's not a good look, nor something we want to do.

Thank you anyways!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants