proxyVerifiedPremium = ConcurrentHashMap.newKeySet();
+ private final VelocityPremiumVerificationManager premiumVerificationManager;
private final ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "authme-velocity-retry");
t.setDaemon(true);
@@ -80,42 +78,10 @@ final class VelocityProxyBridge {
this.logger = logger;
this.configuration = configuration;
this.authenticationStore = authenticationStore;
- }
-
- private void markProxyVerifiedPremium(String normalizedName) {
- if (proxyVerifiedPremium.add(normalizedName)) {
- logger.info("Proxy-verified premium: '{}' authenticated online-mode with Mojang", normalizedName);
- }
- }
-
- /**
- * Records the player as proxy-verified premium when the proxy finished the Mojang authentication
- * phase in online mode. Called from {@code AuthMeVelocityPlugin#onLogin(LoginEvent)}.
- */
- void onLogin(LoginEvent event) {
- Player player = event.getPlayer();
- if (!player.isOnlineMode()) {
- return;
- }
- String normalizedName = normalizeName(player.getUsername());
- // Mojang auth succeeded: the attempt tracking entry is no longer needed.
- pendingVerificationAttempted.remove(normalizedName);
- markProxyVerifiedPremium(normalizedName);
- }
-
- /**
- * Fallback hook on {@link PostLoginEvent}: if the player has a UUID v4 (Mojang-issued), they
- * are recorded as proxy-verified premium even if the {@link LoginEvent} listener missed them.
- *
- * This is a secondary signal only — the primary gate is the backend's UUID comparison
- * ({@code verifiedUuid.equals(auth.getPremiumUuid())}), so a fake v4 UUID injected by a
- * third-party plugin would still be rejected there unless it matches the stored premium UUID.
- */
- void onPostLogin(PostLoginEvent event) {
- Player player = event.getPlayer();
- if (player.getUniqueId() != null && player.getUniqueId().version() == 4) {
- markProxyVerifiedPremium(normalizeName(player.getUsername()));
- }
+ this.premiumVerificationManager =
+ new VelocityPremiumVerificationManager(logger,
+ this::requiresPremiumVerification, this::isPendingPremiumVerification,
+ () -> this.configuration.keepOfflineUuidCompatibility());
}
void reload(VelocityProxyConfiguration configuration) {
@@ -127,6 +93,23 @@ void registerChannels() {
proxyServer.getChannelRegistrar().register(AUTHME_CHANNEL, AUTHME_LEGACY_CHANNEL);
logger.info("Registered AuthMe Velocity bridge channel");
broadcastProxyStartedHandshake();
+ premiumVerificationManager.register();
+ }
+
+ private boolean requiresPremiumVerification(String normalizedName) {
+ return premiumUsernames.contains(normalizedName) || pendingPremiumUsernames.contains(normalizedName);
+ }
+
+ private boolean isPendingPremiumVerification(String normalizedName) {
+ return pendingPremiumUsernames.contains(normalizedName);
+ }
+
+ private void clearPendingPremiumVerification(String normalizedName) {
+ if (pendingPremiumUsernames.remove(normalizedName)) {
+ premiumVerificationManager.clearVerifiedPremium(normalizedName);
+ logger.warn("Cleared pending premium verification for '{}' after a failed proxy-side premium handshake",
+ normalizedName);
+ }
}
private void broadcastProxyStartedHandshake() {
@@ -176,6 +159,9 @@ void logConfigurationDetails() {
logger.info("autoLogin is disabled");
}
+ logger.info("premium.keepOfflineUuidCompatibility is {}",
+ configuration.keepOfflineUuidCompatibility() ? "enabled" : "disabled");
+
if (configuration.sendOnLogoutEnabled() && configuration.sendOnLogoutTarget().isEmpty()) {
logger.warn("sendOnLogout is enabled but unloggedUserServer is empty; logout redirects will be skipped");
}
@@ -234,16 +220,18 @@ void onPluginMessage(PluginMessageEvent event) {
} else if (PREMIUM_UNSET_MESSAGE.equals(parsedMessage.typeId())) {
premiumUsernames.remove(parsedMessage.playerName());
pendingPremiumUsernames.remove(parsedMessage.playerName());
+ premiumVerificationManager.clearVerifiedPremium(parsedMessage.playerName());
logger.debug("Premium disabled for '{}' (proxy cache updated)", parsedMessage.playerName());
} else if (PREMIUM_PENDING_SET_MESSAGE.equals(parsedMessage.typeId())) {
pendingPremiumUsernames.add(parsedMessage.playerName());
+ premiumVerificationManager.clearVerifiedPremium(parsedMessage.playerName());
logger.debug("Pending premium verification started for '{}'", parsedMessage.playerName());
} else if (PREMIUM_LIST_MESSAGE.equals(parsedMessage.typeId())) {
Set newPremiumSet = ConcurrentHashMap.newKeySet();
if (!parsedMessage.playerName().isEmpty()) {
for (String name : parsedMessage.playerName().split(",")) {
if (!name.isEmpty()) {
- newPremiumSet.add(name.trim());
+ newPremiumSet.add(normalizeName(name.trim()));
}
}
}
@@ -262,7 +250,7 @@ void onPluginMessage(PluginMessageEvent event) {
if (!csv.isEmpty()) {
for (String name : csv.split(",")) {
if (!name.isEmpty()) {
- premiumListBuffer.add(name.trim());
+ premiumListBuffer.add(normalizeName(name.trim()));
}
}
}
@@ -302,13 +290,9 @@ void onServerConnected(ServerConnectedEvent event) {
}
String normalizedName = normalizeName(playerName);
+ UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName);
- // Pending players have passed Mojang auth at the proxy, but we must NOT send PERFORM_LOGIN
- // for them: the backend needs to run canBypassWithPremium() to finalize (persist) the premium
- // UUID. Only confirmed premium players (premiumUsernames) trigger the auto-login bypass.
- boolean isPremiumJoin = connectingToAuthServer
- && proxyVerifiedPremium.contains(normalizedName)
- && !pendingPremiumUsernames.contains(normalizedName);
+ boolean isPremiumJoin = connectingToAuthServer && verifiedPremiumUuid != null;
if (!authenticationStore.isAuthenticated(normalizedName) && !isPremiumJoin) {
logger.debug("Skipping auto-login for {} — not authenticated or proxy-verified premium", normalizedName);
return;
@@ -327,7 +311,8 @@ void onServerConnected(ServerConnectedEvent event) {
}
String serverName = currentServer.get().getServer().getServerInfo().getName();
- boolean sent = currentServer.get().sendPluginMessage(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName));
+ boolean sent = currentServer.get().sendPluginMessage(
+ AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid));
if (sent) {
logger.info("Sending auto-login request to server '{}' for player {}", serverName, normalizedName);
initiatePendingLogin(normalizedName);
@@ -337,6 +322,14 @@ void onServerConnected(ServerConnectedEvent event) {
}
}
+ void onPreLogin(PreLoginEvent event) {
+ premiumVerificationManager.onPreLogin(event);
+ }
+
+ void onGameProfileRequest(GameProfileRequestEvent event) {
+ premiumVerificationManager.onGameProfileRequest(event);
+ }
+
void onServerPreConnect(ServerPreConnectEvent event) {
if (!configuration.serverSwitchRequiresAuth()) {
return;
@@ -401,28 +394,6 @@ void onPlayerChat(PlayerChatEvent event) {
event.setResult(PlayerChatEvent.ChatResult.denied());
}
- void onPreLogin(com.velocitypowered.api.event.connection.PreLoginEvent event) {
- String normalizedName = normalizeName(event.getUsername());
- if (premiumUsernames.contains(normalizedName)) {
- event.setResult(com.velocitypowered.api.event.connection.PreLoginEvent.PreLoginComponentResult.forceOnlineMode());
- logger.debug("Forcing online-mode for premium player '{}'", normalizedName);
- } else if (pendingPremiumUsernames.contains(normalizedName)) {
- if (pendingVerificationAttempted.contains(normalizedName)) {
- // The player was already given a forced online-mode attempt but never reached onLogin —
- // meaning Mojang rejected them. Cancel the premium request so they can reconnect normally.
- pendingPremiumUsernames.remove(normalizedName);
- pendingVerificationAttempted.remove(normalizedName);
- logger.info("Pending premium verification failed for '{}' (Mojang auth rejected) — premium request cancelled",
- normalizedName);
- } else {
- // First attempt: force online-mode and track that the attempt is in progress.
- pendingVerificationAttempted.add(normalizedName);
- event.setResult(com.velocitypowered.api.event.connection.PreLoginEvent.PreLoginComponentResult.forceOnlineMode());
- logger.debug("Forcing online-mode for pending premium player '{}'", normalizedName);
- }
- }
- }
-
void onDisconnect(DisconnectEvent event) {
String normalizedName = normalizeName(event.getPlayer().getUsername());
if (pendingAutoLogins.containsKey(normalizedName)) {
@@ -433,12 +404,12 @@ void onDisconnect(DisconnectEvent event) {
logger.debug("Clearing auth state for {} (player disconnected)", normalizedName);
}
authenticationStore.clear(event.getPlayer());
- proxyVerifiedPremium.remove(normalizedName);
- pendingVerificationAttempted.remove(normalizedName);
+ premiumVerificationManager.clearVerifiedPremium(normalizedName);
}
void shutdown() {
logger.info("Shutting down retry scheduler");
+ premiumVerificationManager.shutdown();
retryScheduler.shutdownNow();
}
@@ -463,7 +434,9 @@ private void sendAutoLoginIfAlreadySwitched(String normalizedName, RegisteredSer
String currentServerName = currentServer.getServerInfo().getName();
logger.info("Player {} already on server '{}' when login message arrived — sending auto-login immediately",
normalizedName, currentServerName);
- boolean sent = currentConn.get().sendPluginMessage(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName));
+ UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName);
+ boolean sent = currentConn.get().sendPluginMessage(
+ AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid));
if (sent) {
initiatePendingLogin(normalizedName);
} else {
@@ -543,7 +516,9 @@ private void scheduleRetry(String normalizedName) {
String serverName = serverOpt.get().getServer().getServerInfo().getName();
logger.debug("Retrying auto-login for {} on server '{}' (attempt {}/{})",
normalizedName, serverName, current + 1, MAX_RETRIES);
- serverOpt.get().sendPluginMessage(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName));
+ UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName);
+ serverOpt.get().sendPluginMessage(AUTHME_CHANNEL,
+ createPerformLoginMessage(normalizedName, verifiedPremiumUuid));
scheduleRetry(normalizedName);
}, 1, TimeUnit.SECONDS);
}
@@ -573,13 +548,15 @@ private ParsedMessage parseMessage(byte[] data) {
}
}
- private byte[] createPerformLoginMessage(String normalizedName) {
+ private byte[] createPerformLoginMessage(String normalizedName, UUID verifiedPremiumUuid) {
long timestamp = System.currentTimeMillis();
- String hmac = ProxyMessageSecurity.computeHmac(configuration.sharedSecret(), normalizedName, timestamp);
+ String hmac = ProxyMessageSecurity.computeHmac(
+ configuration.sharedSecret(), normalizedName, timestamp, verifiedPremiumUuid);
ByteArrayDataOutput output = ByteStreams.newDataOutput();
output.writeUTF(PERFORM_LOGIN_MESSAGE);
output.writeUTF(normalizedName);
output.writeLong(timestamp);
+ output.writeUTF(verifiedPremiumUuid == null ? "" : verifiedPremiumUuid.toString());
output.writeUTF(hmac);
return output.toByteArray();
}
diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyConfiguration.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyConfiguration.java
index ac4ca4d05..fc1976146 100644
--- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyConfiguration.java
+++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyConfiguration.java
@@ -25,13 +25,15 @@ final class VelocityProxyConfiguration {
private final boolean chatRequiresAuth;
private final String loginServer;
private final String sharedSecret;
+ private final boolean keepOfflineUuidCompatibility;
VelocityProxyConfiguration(Set authServers, boolean allServersAreAuthServers,
boolean serverSwitchRequiresAuth, String serverSwitchKickMessage,
boolean autoLoginEnabled, boolean sendOnLogoutEnabled,
String sendOnLogoutTarget, boolean commandsRequireAuth,
Set commandWhitelist, boolean chatRequiresAuth,
- String loginServer, String sharedSecret) {
+ String loginServer, String sharedSecret,
+ boolean keepOfflineUuidCompatibility) {
this.authServers = authServers;
this.allServersAreAuthServers = allServersAreAuthServers;
this.serverSwitchRequiresAuth = serverSwitchRequiresAuth;
@@ -44,6 +46,7 @@ final class VelocityProxyConfiguration {
this.chatRequiresAuth = chatRequiresAuth;
this.loginServer = normalizeServerName(loginServer);
this.sharedSecret = sharedSecret;
+ this.keepOfflineUuidCompatibility = keepOfflineUuidCompatibility;
}
static VelocityProxyConfiguration from(SettingsManager settingsManager) {
@@ -59,7 +62,8 @@ static VelocityProxyConfiguration from(SettingsManager settingsManager) {
normalizeCommandAliases(settingsManager.getProperty(VelocityConfigProperties.COMMANDS_WHITELIST)),
settingsManager.getProperty(VelocityConfigProperties.CHAT_REQUIRES_AUTH),
settingsManager.getProperty(VelocityConfigProperties.LOGIN_SERVER),
- settingsManager.getProperty(VelocityConfigProperties.PROXY_SHARED_SECRET));
+ settingsManager.getProperty(VelocityConfigProperties.PROXY_SHARED_SECRET),
+ settingsManager.getProperty(VelocityConfigProperties.PREMIUM_KEEP_OFFLINE_UUID_COMPATIBILITY));
}
Set authServers() {
@@ -106,6 +110,10 @@ String sharedSecret() {
return sharedSecret;
}
+ boolean keepOfflineUuidCompatibility() {
+ return keepOfflineUuidCompatibility;
+ }
+
boolean isAuthServer(RegisteredServer server) {
return allServersAreAuthServers || authServers.contains(normalizeServerName(server.getServerInfo().getName()));
}
diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/config/VelocityConfigProperties.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/config/VelocityConfigProperties.java
index 6fec8773a..fc491a5a1 100644
--- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/config/VelocityConfigProperties.java
+++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/config/VelocityConfigProperties.java
@@ -67,6 +67,14 @@ public final class VelocityConfigProperties implements SettingsHolder {
public static final Property PROXY_SHARED_SECRET =
newProperty("proxySharedSecret", "");
+ @Comment({
+ "Keep verified premium players on the backend offline UUID for plugin compatibility.",
+ "When false, premium players keep their Mojang UUID on the backend instead.",
+ "Velocity can do both modes natively; false is the default."
+ })
+ public static final Property PREMIUM_KEEP_OFFLINE_UUID_COMPATIBILITY =
+ newProperty("premium.keepOfflineUuidCompatibility", false);
+
private VelocityConfigProperties() {
}
}
diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/ProxyPremiumLoginVerifier.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/ProxyPremiumLoginVerifier.java
new file mode 100644
index 000000000..e0f88aea2
--- /dev/null
+++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/ProxyPremiumLoginVerifier.java
@@ -0,0 +1,234 @@
+package fr.xephi.authme.velocity.premium;
+
+import javax.crypto.Cipher;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.math.BigInteger;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.Consumer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+final class ProxyPremiumLoginVerifier {
+
+ private static final String HAS_JOINED_URL =
+ "https://sessionserver.mojang.com/session/minecraft/hasJoined";
+ private static final Pattern UUID_PATTERN =
+ Pattern.compile("\"id\"\\s*:\\s*\"([0-9a-fA-F]{32})\"");
+ private static final long VERIFIED_TTL_MS = 60_000L;
+ private static final long PENDING_TTL_MS = 30_000L;
+
+ private final KeyPair rsaKeyPair;
+ private final SecureRandom secureRandom;
+ private final Consumer warningLogger;
+ private final ExecutorService verificationExecutor;
+ private final ConcurrentHashMap pending = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap verified = new ConcurrentHashMap<>();
+
+ ProxyPremiumLoginVerifier(String threadName, Consumer warningLogger) {
+ this.warningLogger = warningLogger;
+ this.secureRandom = new SecureRandom();
+ this.verificationExecutor = Executors.newSingleThreadExecutor(r -> {
+ Thread thread = new Thread(r, threadName);
+ thread.setDaemon(true);
+ return thread;
+ });
+ try {
+ KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+ gen.initialize(1024, secureRandom);
+ this.rsaKeyPair = gen.generateKeyPair();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("RSA algorithm not available", e);
+ }
+ }
+
+ PublicKey getPublicKey() {
+ return rsaKeyPair.getPublic();
+ }
+
+ byte[] startVerification(String connectionKey, String username, UUID playerUuid, boolean pendingPremiumEnrollment) {
+ evictStalePendingEntries();
+ verified.remove(username.toLowerCase(Locale.ROOT));
+ byte[] verifyToken = new byte[4];
+ secureRandom.nextBytes(verifyToken);
+ pending.put(connectionKey,
+ new PendingVerification(username, playerUuid, verifyToken, System.currentTimeMillis(), pendingPremiumEnrollment));
+ return verifyToken;
+ }
+
+ boolean hasPending(String connectionKey) {
+ return pending.containsKey(connectionKey);
+ }
+
+ String getPendingUsername(String connectionKey) {
+ PendingVerification pendingVerification = pending.get(connectionKey);
+ return pendingVerification != null ? pendingVerification.username() : null;
+ }
+
+ UUID getPendingPlayerUuid(String connectionKey) {
+ PendingVerification pendingVerification = pending.get(connectionKey);
+ return pendingVerification != null ? pendingVerification.playerUuid() : null;
+ }
+
+ boolean isPendingPremiumEnrollment(String connectionKey) {
+ PendingVerification pendingVerification = pending.get(connectionKey);
+ return pendingVerification != null && pendingVerification.pendingPremiumEnrollment();
+ }
+
+ void cleanupPending(String connectionKey) {
+ pending.remove(connectionKey);
+ }
+
+ byte[] decryptData(byte[] encrypted) throws GeneralSecurityException {
+ return rsaDecrypt(encrypted);
+ }
+
+ CompletableFuture> completeVerification(
+ String connectionKey, byte[] sharedSecret, byte[] encryptedVerifyToken) {
+ PendingVerification pendingVerification = pending.remove(connectionKey);
+ if (pendingVerification == null) {
+ return CompletableFuture.completedFuture(Optional.empty());
+ }
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ byte[] decryptedToken = rsaDecrypt(encryptedVerifyToken);
+ if (!Arrays.equals(decryptedToken, pendingVerification.verifyToken())) {
+ warningLogger.accept("Proxy premium verification failed for '" + pendingVerification.username()
+ + "': verify token mismatch");
+ return Optional.empty();
+ }
+ String serverHash = computeServerHash(sharedSecret);
+ return hasJoined(pendingVerification.username(), serverHash);
+ } catch (Exception e) {
+ warningLogger.accept("Proxy premium verification failed for '" + pendingVerification.username()
+ + "': " + e.getMessage());
+ return Optional.empty();
+ }
+ }, verificationExecutor);
+ }
+
+ void storeVerified(String username, UUID mojangUuid) {
+ verified.put(username.toLowerCase(Locale.ROOT), new VerifiedSession(mojangUuid, System.currentTimeMillis()));
+ }
+
+ UUID getVerifiedUuid(String username) {
+ String normalizedName = username.toLowerCase(Locale.ROOT);
+ VerifiedSession verifiedSession = verified.get(normalizedName);
+ if (verifiedSession == null) {
+ return null;
+ }
+ if (System.currentTimeMillis() - verifiedSession.verifiedAt() > VERIFIED_TTL_MS) {
+ verified.remove(normalizedName);
+ return null;
+ }
+ return verifiedSession.mojangUuid();
+ }
+
+ void clearVerified(String username) {
+ verified.remove(username.toLowerCase(Locale.ROOT));
+ }
+
+ void shutdown() {
+ verificationExecutor.shutdownNow();
+ pending.clear();
+ verified.clear();
+ }
+
+ private void evictStalePendingEntries() {
+ long now = System.currentTimeMillis();
+ pending.entrySet().removeIf(entry -> now - entry.getValue().startedAt() > PENDING_TTL_MS);
+ }
+
+ private byte[] rsaDecrypt(byte[] encrypted) throws GeneralSecurityException {
+ Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+ cipher.init(Cipher.DECRYPT_MODE, rsaKeyPair.getPrivate());
+ return cipher.doFinal(encrypted);
+ }
+
+ private String computeServerHash(byte[] sharedSecret) throws NoSuchAlgorithmException {
+ MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+ sha1.update("".getBytes(StandardCharsets.ISO_8859_1));
+ sha1.update(sharedSecret);
+ sha1.update(rsaKeyPair.getPublic().getEncoded());
+ return new BigInteger(sha1.digest()).toString(16);
+ }
+
+ private Optional hasJoined(String username, String serverHash) {
+ try {
+ HttpURLConnection connection = openGet(HAS_JOINED_URL + "?username=" + username + "&serverId=" + serverHash);
+ int code = connection.getResponseCode();
+ if (code == HttpURLConnection.HTTP_NO_CONTENT || code == HttpURLConnection.HTTP_NOT_FOUND) {
+ return Optional.empty();
+ }
+ if (code != HttpURLConnection.HTTP_OK) {
+ warningLogger.accept("Mojang hasJoined returned " + code + " for '" + username + "'");
+ return Optional.empty();
+ }
+ return parseUuid(readBody(connection), username);
+ } catch (IOException e) {
+ warningLogger.accept("Failed to contact Mojang session server for '" + username + "': " + e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private HttpURLConnection openGet(String urlString) throws IOException {
+ HttpURLConnection connection = (HttpURLConnection) new URL(urlString).openConnection();
+ connection.setRequestMethod("GET");
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(5000);
+ return connection;
+ }
+
+ private Optional parseUuid(String body, String username) {
+ Matcher matcher = UUID_PATTERN.matcher(body);
+ if (!matcher.find()) {
+ return Optional.empty();
+ }
+ String raw = matcher.group(1);
+ String dashed = raw.substring(0, 8) + "-" + raw.substring(8, 12) + "-"
+ + raw.substring(12, 16) + "-" + raw.substring(16, 20) + "-" + raw.substring(20);
+ try {
+ return Optional.of(UUID.fromString(dashed));
+ } catch (IllegalArgumentException e) {
+ warningLogger.accept("Mojang returned an unparseable UUID for '" + username + "': " + raw);
+ return Optional.empty();
+ }
+ }
+
+ private static String readBody(HttpURLConnection connection) throws IOException {
+ StringBuilder builder = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ builder.append(line);
+ }
+ }
+ return builder.toString();
+ }
+
+ private record PendingVerification(
+ String username, UUID playerUuid, byte[] verifyToken, long startedAt, boolean pendingPremiumEnrollment) {
+ }
+
+ private record VerifiedSession(UUID mojangUuid, long verifiedAt) {
+ }
+}
diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java
new file mode 100644
index 000000000..71349a343
--- /dev/null
+++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java
@@ -0,0 +1,87 @@
+package fr.xephi.authme.velocity.premium;
+
+import com.velocitypowered.api.event.connection.PreLoginEvent;
+import com.velocitypowered.api.event.player.GameProfileRequestEvent;
+import com.velocitypowered.api.util.UuidUtils;
+import org.slf4j.Logger;
+
+import java.util.Locale;
+import java.util.UUID;
+import java.util.function.BooleanSupplier;
+import java.util.function.Predicate;
+
+public final class VelocityPremiumVerificationManager {
+
+ private final Logger logger;
+ private final Predicate requiresVerification;
+ private final Predicate isPendingVerification;
+ private final BooleanSupplier keepOfflineUuidCompatibility;
+ private final ProxyPremiumLoginVerifier loginVerifier;
+ private boolean registered;
+
+ public VelocityPremiumVerificationManager(Logger logger,
+ Predicate requiresVerification,
+ Predicate isPendingVerification,
+ BooleanSupplier keepOfflineUuidCompatibility) {
+ this.logger = logger;
+ this.requiresVerification = requiresVerification;
+ this.isPendingVerification = isPendingVerification;
+ this.keepOfflineUuidCompatibility = keepOfflineUuidCompatibility;
+ this.loginVerifier = new ProxyPremiumLoginVerifier("authme-velocity-premium",
+ message -> this.logger.warn(message));
+ }
+
+ public void register() {
+ if (registered) {
+ return;
+ }
+ registered = true;
+ logger.info("Registered native Velocity premium verification");
+ }
+
+ public void onPreLogin(PreLoginEvent event) {
+ String normalizedName = normalize(event.getUsername());
+ if (requiresVerification.test(normalizedName)) {
+ event.setResult(PreLoginEvent.PreLoginComponentResult.forceOnlineMode());
+ }
+ }
+
+ public void onGameProfileRequest(GameProfileRequestEvent event) {
+ String normalizedName = normalize(event.getUsername());
+ if (!requiresVerification.test(normalizedName) || !event.isOnlineMode()) {
+ return;
+ }
+
+ UUID verifiedPremiumUuid = event.getOriginalProfile().getId();
+ loginVerifier.storeVerified(normalizedName, verifiedPremiumUuid);
+ if (keepOfflineUuidCompatibility.getAsBoolean()) {
+ event.setGameProfile(event.getGameProfile().withId(UuidUtils.generateOfflinePlayerUuid(event.getUsername())));
+ }
+
+ if (isPendingVerification.test(normalizedName)) {
+ logger.info("Premium enrollment for '{}' was verified on the Velocity proxy", normalizedName);
+ } else {
+ logger.debug("Verified premium login for '{}' on the Velocity proxy", normalizedName);
+ }
+ }
+
+ public UUID getVerifiedPremiumUuid(String normalizedName) {
+ return loginVerifier.getVerifiedUuid(normalizedName);
+ }
+
+ public void clearVerifiedPremium(String normalizedName) {
+ loginVerifier.clearVerified(normalizedName);
+ }
+
+ public void shutdown() {
+ if (!registered) {
+ return;
+ }
+ registered = false;
+ loginVerifier.shutdown();
+ }
+
+ private static String normalize(String username) {
+ return username.toLowerCase(Locale.ROOT);
+ }
+}
diff --git a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityConfigManagerTest.java b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityConfigManagerTest.java
index 686186f20..c2819c2d2 100644
--- a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityConfigManagerTest.java
+++ b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityConfigManagerTest.java
@@ -31,6 +31,7 @@ void shouldCreateConfigFileWithDefaults() {
assertTrue(configManager.getConfiguration().isWhitelistedCommand("/login"));
assertTrue(configManager.getConfiguration().chatRequiresAuth());
assertFalse(configManager.getConfiguration().sharedSecret().isEmpty());
+ assertFalse(configManager.getConfiguration().keepOfflineUuidCompatibility());
}
@Test
@@ -45,17 +46,19 @@ void shouldPreserveExistingSharedSecret() throws IOException {
@Test
void shouldNormalizeConfiguredServersAndLogoutTarget() throws IOException {
Files.writeString(tempDirectory.resolve("config.yml"), """
- authServers:
- - Lobby
- - HUB
- allServersAreAuthServers: false
- serverSwitch:
- requiresAuth: false
- kickMessage: Please authenticate first.
- autoLogin: true
- sendOnLogout: true
- unloggedUserServer: LiMbO
- """);
+authServers:
+- Lobby
+- HUB
+allServersAreAuthServers: false
+serverSwitch:
+ requiresAuth: false
+ kickMessage: Please authenticate first.
+autoLogin: true
+sendOnLogout: true
+unloggedUserServer: LiMbO
+premium:
+ keepOfflineUuidCompatibility: true
+""");
VelocityProxyConfiguration configuration = new VelocityConfigManager(tempDirectory).getConfiguration();
@@ -66,6 +69,7 @@ void shouldNormalizeConfiguredServersAndLogoutTarget() throws IOException {
assertTrue(configuration.autoLoginEnabled());
assertTrue(configuration.sendOnLogoutEnabled());
assertEquals("limbo", configuration.sendOnLogoutTarget());
+ assertTrue(configuration.keepOfflineUuidCompatibility());
}
@Test
diff --git a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java
index 939999d41..ab057ebf0 100644
--- a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java
+++ b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java
@@ -4,6 +4,7 @@
import com.google.common.io.ByteStreams;
import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.event.command.CommandExecuteEvent;
+import com.velocitypowered.api.event.connection.PreLoginEvent;
import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.event.player.PlayerChatEvent;
@@ -12,6 +13,7 @@
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.ConnectionRequestBuilder;
+import com.velocitypowered.api.proxy.InboundConnection;
import com.velocitypowered.api.proxy.ServerConnection;
import com.velocitypowered.api.proxy.messages.ChannelRegistrar;
import com.velocitypowered.api.proxy.server.RegisteredServer;
@@ -191,7 +193,7 @@ void shouldRedirectPlayerOnLogoutWhenConfigured() {
VelocityProxyBridge bridge = new VelocityProxyBridge(
proxyServer, logger, new VelocityProxyConfiguration(Set.of("lobby"), false, true,
"Authentication required.", true, true, "limbo", true,
- Set.of("/login", "/register"), true, "", ""),
+ Set.of("/login", "/register"), true, "", "", false),
new VelocityAuthenticationStore());
bridge.onPluginMessage(pluginMessageEvent);
@@ -435,7 +437,7 @@ void shouldAllowCommandIfSourceIsNotAPlayer() {
void shouldNotBlockCommandIfCommandsRequireAuthIsDisabled() {
VelocityProxyConfiguration config = new VelocityProxyConfiguration(
Set.of("lobby"), false, true, "Authentication required.", false, false, "",
- false, Set.of("/login"), true, "", "");
+ false, Set.of("/login"), true, "", "", false);
VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, config, new VelocityAuthenticationStore());
bridge.onCommandExecute(commandEvent);
@@ -507,7 +509,7 @@ void shouldAllowChatIfPlayerHasNoCurrentServer() {
void shouldNotBlockChatIfChatRequiresAuthIsDisabled() {
VelocityProxyConfiguration config = new VelocityProxyConfiguration(
Set.of("lobby"), false, true, "Authentication required.", false, false, "",
- true, Set.of("/login"), false, "", "");
+ true, Set.of("/login"), false, "", "", false);
VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, config, new VelocityAuthenticationStore());
bridge.onPlayerChat(chatEvent);
@@ -516,101 +518,22 @@ void shouldNotBlockChatIfChatRequiresAuthIsDisabled() {
}
@Test
- void shouldUpdatePremiumSetAfterReceivingAllChunks() {
+ void shouldForceOnlineModeAfterChunkedPremiumListResync() {
given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward());
given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL);
given(pluginMessageEvent.getSource()).willReturn(sourceConnection);
+ given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, true, "Alice"));
given(sourceConnection.getServer()).willReturn(authServer);
given(authServer.getServerInfo()).willReturn(authServerInfo);
given(authServerInfo.getName()).willReturn("lobby");
- VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore());
-
- given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, false, "alice,bob"));
- bridge.onPluginMessage(pluginMessageEvent);
- given(pluginMessageEvent.getData()).willReturn(createChunkPayload(1, true, "charlie"));
- bridge.onPluginMessage(pluginMessageEvent);
-
- com.velocitypowered.api.event.connection.PreLoginEvent preLoginEvent =
- mock(com.velocitypowered.api.event.connection.PreLoginEvent.class);
- given(preLoginEvent.getUsername()).willReturn("alice");
- bridge.onPreLogin(preLoginEvent);
- verify(preLoginEvent).setResult(any());
- }
- @Test
- void shouldNotUpdatePremiumSetOnPartialChunkOnly() {
- given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward());
- given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL);
- given(pluginMessageEvent.getSource()).willReturn(sourceConnection);
- given(sourceConnection.getServer()).willReturn(authServer);
- given(authServer.getServerInfo()).willReturn(authServerInfo);
- given(authServerInfo.getName()).willReturn("lobby");
- VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore());
-
- // Only first chunk (not last) — set must not be updated yet
- given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, false, "alice,bob"));
- bridge.onPluginMessage(pluginMessageEvent);
-
- com.velocitypowered.api.event.connection.PreLoginEvent preLoginEvent =
- mock(com.velocitypowered.api.event.connection.PreLoginEvent.class);
- given(preLoginEvent.getUsername()).willReturn("alice");
- bridge.onPreLogin(preLoginEvent);
- verify(preLoginEvent, never()).setResult(any());
- }
-
- @Test
- void shouldPreservePendingPremiumStateAcrossDisconnectReconnect() {
- given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward());
- given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL);
- given(pluginMessageEvent.getSource()).willReturn(sourceConnection);
- given(sourceConnection.getServer()).willReturn(authServer);
- given(authServer.getServerInfo()).willReturn(authServerInfo);
- given(authServerInfo.getName()).willReturn("lobby");
- given(player.getUsername()).willReturn("alice");
VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore());
-
- // Backend kicks the player for premium verification and sends PREMIUM_PENDING_SET
- given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.pending.set", "alice"));
bridge.onPluginMessage(pluginMessageEvent);
- // Player is kicked by the backend — disconnect must NOT clear the pending state
- bridge.onDisconnect(new DisconnectEvent(player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN));
-
- // On reconnect, Velocity must still force online-mode for the pending player
- com.velocitypowered.api.event.connection.PreLoginEvent reconnectAttempt =
- mock(com.velocitypowered.api.event.connection.PreLoginEvent.class);
- given(reconnectAttempt.getUsername()).willReturn("alice");
- bridge.onPreLogin(reconnectAttempt);
- verify(reconnectAttempt).setResult(any());
- }
-
- @Test
- void shouldForceOnlineModeOnFirstPendingAttemptThenCancelOnSecond() {
- given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward());
- given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL);
- given(pluginMessageEvent.getSource()).willReturn(sourceConnection);
- given(sourceConnection.getServer()).willReturn(authServer);
- given(authServer.getServerInfo()).willReturn(authServerInfo);
- given(authServerInfo.getName()).willReturn("lobby");
- VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore());
-
- given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.pending.set", "alice"));
- bridge.onPluginMessage(pluginMessageEvent);
+ PreLoginEvent event = new PreLoginEvent(mock(InboundConnection.class), "Alice", null);
+ bridge.onPreLogin(event);
- // First reconnect: should force online-mode so Mojang can verify
- com.velocitypowered.api.event.connection.PreLoginEvent firstAttempt =
- mock(com.velocitypowered.api.event.connection.PreLoginEvent.class);
- given(firstAttempt.getUsername()).willReturn("alice");
- bridge.onPreLogin(firstAttempt);
- verify(firstAttempt).setResult(any());
-
- // Mojang rejected the player (no onLogin fired) — second reconnect should cancel the pending
- // request and NOT force online-mode, so the player can rejoin in offline mode
- com.velocitypowered.api.event.connection.PreLoginEvent secondAttempt =
- mock(com.velocitypowered.api.event.connection.PreLoginEvent.class);
- given(secondAttempt.getUsername()).willReturn("alice");
- bridge.onPreLogin(secondAttempt);
- verify(secondAttempt, never()).setResult(any());
+ assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(), event.getResult().toString());
}
private static byte[] createChunkPayload(int seq, boolean last, String csv) {
@@ -624,7 +547,7 @@ private static VelocityProxyConfiguration createConfiguration() {
return new VelocityProxyConfiguration(Set.of("lobby"), false, true,
"Authentication required.", true, false, "", true,
Set.of("/login", "/register", "/l", "/reg", "/email", "/captcha", "/2fa", "/totp", "/log"),
- true, "", "test-secret");
+ true, "", "test-secret", false);
}
private static byte[] createAuthMePayload(String typeId, String playerName) {
@@ -639,8 +562,9 @@ private static void assertPerformLoginPayload(byte[] payload, String expectedPla
assertEquals("perform.login", in.readUTF());
assertEquals(expectedPlayerName, in.readUTF());
long timestamp = in.readLong();
+ assertEquals("", in.readUTF());
String hmac = in.readUTF();
assertTrue(Math.abs(System.currentTimeMillis() - timestamp) < 5000L, "timestamp should be recent");
- assertEquals(ProxyMessageSecurity.computeHmac(sharedSecret, expectedPlayerName, timestamp), hmac);
+ assertEquals(ProxyMessageSecurity.computeHmac(sharedSecret, expectedPlayerName, timestamp, null), hmac);
}
}
diff --git a/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java b/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java
new file mode 100644
index 000000000..0246461de
--- /dev/null
+++ b/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java
@@ -0,0 +1,76 @@
+package fr.xephi.authme.velocity.premium;
+
+import com.velocitypowered.api.event.connection.PreLoginEvent;
+import com.velocitypowered.api.event.player.GameProfileRequestEvent;
+import com.velocitypowered.api.proxy.InboundConnection;
+import com.velocitypowered.api.util.GameProfile;
+import com.velocitypowered.api.util.UuidUtils;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+
+class VelocityPremiumVerificationManagerTest {
+
+ @Test
+ void shouldForceOnlineModeForPremiumUser() {
+ VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager(
+ mock(Logger.class), "alice"::equals, normalizedName -> false, () -> false);
+
+ PreLoginEvent event = new PreLoginEvent(mock(InboundConnection.class), "Alice", null);
+ manager.onPreLogin(event);
+
+ assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(),
+ event.getResult().toString());
+ }
+
+ @Test
+ void shouldRewriteVerifiedProfileToOfflineUuid() {
+ VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager(
+ mock(Logger.class), "alice"::equals, normalizedName -> false, () -> true);
+ UUID mojangUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8");
+ GameProfile originalProfile = new GameProfile(mojangUuid, "Alice", List.of());
+ GameProfileRequestEvent event = new GameProfileRequestEvent(
+ mock(InboundConnection.class), originalProfile, true);
+
+ manager.onGameProfileRequest(event);
+
+ assertEquals(UuidUtils.generateOfflinePlayerUuid("Alice"), event.getGameProfile().getId());
+ assertEquals(mojangUuid, manager.getVerifiedPremiumUuid("alice"));
+ }
+
+ @Test
+ void shouldIgnoreOfflineModeProfileRequest() {
+ VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager(
+ mock(Logger.class), "alice"::equals, normalizedName -> false, () -> true);
+ UUID mojangUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8");
+ GameProfile originalProfile = new GameProfile(mojangUuid, "Alice", List.of());
+ GameProfileRequestEvent event = new GameProfileRequestEvent(
+ mock(InboundConnection.class), originalProfile, false);
+
+ manager.onGameProfileRequest(event);
+
+ assertEquals(mojangUuid, event.getGameProfile().getId());
+ assertNull(manager.getVerifiedPremiumUuid("alice"));
+ }
+
+ @Test
+ void shouldKeepMojangUuidWhenOfflineCompatibilityDisabled() {
+ VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager(
+ mock(Logger.class), "alice"::equals, normalizedName -> false, () -> false);
+ UUID mojangUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8");
+ GameProfile originalProfile = new GameProfile(mojangUuid, "Alice", List.of());
+ GameProfileRequestEvent event = new GameProfileRequestEvent(
+ mock(InboundConnection.class), originalProfile, true);
+
+ manager.onGameProfileRequest(event);
+
+ assertEquals(mojangUuid, event.getGameProfile().getId());
+ assertEquals(mojangUuid, manager.getVerifiedPremiumUuid("alice"));
+ }
+}
diff --git a/docs/config.md b/docs/config.md
index 4207d9c5b..3018345f7 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1,5 +1,5 @@
-
+
## AuthMe Configuration
The first time you run AuthMe it will create a config.yml file in the plugins/AuthMe folder,
@@ -234,7 +234,8 @@ settings:
displayOtherAccounts: true
# Spawn priority; values: authme, essentials, cmi, multiverse, default, server
# Use "server" to apply the world's spawnRadius gamerule (players land at a random position
- # within the configured radius around the world spawn). Use "default" for the exact world spawn.
+ # within the configured radius around the world spawn).
+ # Use "default" for the exact world spawn.
spawnPriority: authme,essentials,cmi,multiverse,default
# Maximum Login authorized by IP
maxLoginPerIp: 0
@@ -470,6 +471,7 @@ Hooks:
# Shared secret used to verify perform.login messages from the AuthMe proxy plugin.
# Must match the proxySharedSecret value in your AuthMe Velocity or Bungee proxy config.
# All backend servers must have the same value set here.
+ # Leaving this empty disables auto-login: all perform.login messages will be rejected.
proxySharedSecret: ''
# Do we need to disable Essentials SocialSpy on join?
disableSocialSpy: false
@@ -647,4 +649,4 @@ To change settings on a running server, save your changes to config.yml and use
---
-This page was automatically generated on the [AuthMe/AuthMeReloaded repository](https://github.com/AuthMe/AuthMeReloaded/tree/master/docs/) on Wed May 06 11:56:53 CEST 2026
+This page was automatically generated on the [AuthMe/AuthMeReloaded repository](https://github.com/AuthMe/AuthMeReloaded/tree/master/docs/) on Tue May 19 08:22:43 CEST 2026
diff --git a/docs/premium.md b/docs/premium.md
index 8efb1b94a..73eec4d01 100644
--- a/docs/premium.md
+++ b/docs/premium.md
@@ -7,23 +7,47 @@ dialog box.
## Requirements
-- **PacketEvents** must be installed as a separate plugin on the server, **unless** you
- are using proxy mode (see [Behind a proxy](#behind-a-proxy)).
- Without PacketEvents in direct-connection mode, premium bypass is disabled at startup
- (AuthMe logs a warning and falls back to normal password authentication for everyone).
-- For direct connections: an **offline-mode server** that clients reach without a proxy.
+- **Direct connections (no proxy):**
+ - the backend must run in **offline mode**
+ - **PacketEvents** must be installed on the backend server
+- **Behind a proxy:**
+ - install the matching AuthMe proxy plugin:
+ - **authme-velocity** for Velocity
+ - **authme-bungee** for BungeeCord / Waterfall
+ - set `Hooks.bungeecord: true` on every backend
+ - set the same shared secret in the proxy config and in `Hooks.proxySharedSecret` on every backend
+ - choose the proxy UUID mode with `premium.keepOfflineUuidCompatibility`:
+ - `false` (**default**) keeps the Mojang UUID on the backend
+ - `true` preserves the backend offline UUID v3 for plugin compatibility
+
+Without the required premium-verification component for your topology, AuthMe fails closed
+and falls back to normal password authentication.
## Setup
-### 1. Install PacketEvents
+### 1. Install the required verification component
-Download **PacketEvents 2.x** and drop the jar into your `plugins/` folder.
+- **Direct backend only:** install **PacketEvents 2.x** in `plugins/`
+- **Velocity proxy:** install **AuthMe Velocity**
+- **BungeeCord / Waterfall proxy:** install **AuthMe Bungee**
+ - install **PacketEvents 2.x** on the proxy only if `premium.keepOfflineUuidCompatibility: true`
### 2. Enable premium mode in `config.yml`
```yaml
settings:
enablePremium: true
+
+Hooks:
+ # Required only when using AuthMe behind Velocity/Bungee
+ bungeecord: true
+ proxySharedSecret: "same-secret-as-proxy"
+```
+
+```yaml
+# Proxy config (Velocity or Bungee)
+premium:
+ keepOfflineUuidCompatibility: false
```
### 3. Enroll players
@@ -91,12 +115,13 @@ UI shown, no password field displayed.
## Behind a proxy
-### Online-mode proxy (Velocity / BungeeCord online-mode)
+### AuthMe proxy plugin flow
+
+For premium mode behind a proxy, use the matching AuthMe proxy plugin instead of relying on
+plain proxy UUID forwarding.
-When Velocity or BungeeCord runs in **online mode**, the proxy authenticates players with
-Mojang before they reach the backend. The proxy then forwards the real Mojang UUID to the
-backend via IP forwarding. AuthMe uses that forwarded UUID directly — no PacketEvents
-required, no synthetic `ENCRYPTION_REQUEST` sent.
+The proxy verifies premium players first, then forwards a **signed** premium claim to the
+backend. The backend UUID behavior depends on `premium.keepOfflineUuidCompatibility`.
**Backend configuration:**
@@ -105,38 +130,61 @@ settings:
enablePremium: true
Hooks:
- bungeecord: true # trust the UUID forwarded by the proxy
+ bungeecord: true
+ proxySharedSecret: "same-secret-as-proxy"
```
-**Proxy configuration requirements:**
+**Per-proxy behavior:**
-| Proxy | Required settings |
-|---|---|
-| Velocity | `player-info-forwarding-mode: MODERN` in `velocity.toml` + shared secret in `paper-global.yml` (or equivalent) |
-| BungeeCord | `ip_forward: true` in BungeeCord `config.yml` + `bungeecord: true` in backend `spigot.yml` |
+| Proxy | `premium.keepOfflineUuidCompatibility: false` (default) | `premium.keepOfflineUuidCompatibility: true` |
+|---|---|---|
+| Velocity | Native per-player online-mode login, Mojang UUID forwarded to backend | Native per-player online-mode login + rewrite back to offline UUID v3 |
+| BungeeCord / Waterfall | Local per-player online-mode handshake, Mojang UUID forwarded to backend | PacketEvents login-phase verification, then resume offline login with offline UUID v3 |
-> **Security:** with `Hooks.bungeecord: true` the backend trusts the UUID forwarded by the proxy.
-> The backend port **must** be firewalled so only the proxy can reach it — otherwise
-> anyone could connect directly with an arbitrary UUID and bypass authentication.
+**Requirements by mode:**
-PacketEvents is **not** required in this configuration.
+| Mode | Velocity | BungeeCord / Waterfall |
+|---|---|---|
+| `false` | No PacketEvents required | No PacketEvents required |
+| `true` | No PacketEvents required | PacketEvents required on the proxy |
-### Offline-mode proxy with AuthMe proxy plugin
+**What the backend trusts:**
-When the proxy runs in **offline mode**, install the matching AuthMe proxy plugin on the proxy:
+1. the `perform.login` message must have a valid HMAC using `Hooks.proxySharedSecret`
+2. the optional Mojang UUID inside that message must match either:
+ - the player's stored premium UUID, or
+ - the pending premium enrollment being finalized
-- **authme-velocity** for Velocity
-- **authme-bungee** for BungeeCord
+If either check fails, the premium auto-login request is rejected.
-These plugins maintain a list of premium-enrolled players and force per-player Mojang
-authentication for them via `PreLoginEvent`. The proxy then forwards the verified Mojang UUID
-to the backend the same way as in online-mode proxy setup. Set `Hooks.bungeecord: true` on the backend.
+**Premium cache synchronization:**
-The premium player list is synchronised automatically:
- When the proxy plugin starts, the backend sends the full list of enrolled premium usernames.
- When a player runs `/premium` or `/authme premium `, the backend notifies the proxy
immediately so the cache stays up to date.
+> **Security:** `Hooks.bungeecord: true` enables proxy-backed login handling, so backend ports
+> must only be reachable by the proxy. Do not expose backend servers directly to players.
+
+### Choosing the backend UUID mode
+
+`premium.keepOfflineUuidCompatibility` is a **proxy-side feature flag**:
+
+- `false` (**default**): premium players keep their **Mojang UUID v4** on the backend
+- `true`: premium players keep the backend **offline UUID v3** while the proxy still proves their premium identity
+
+Use `false` if you want the simplest premium proxy flow. Use `true` only when backend-side
+plugin compatibility requires offline UUID semantics.
+
+### Plain online-mode proxy forwarding
+
+If you run a proxy in normal online mode **without** the AuthMe proxy plugin, the backend sees
+the forwarded Mojang UUID from the proxy.
+
+That setup can work for general proxy forwarding, but it does **not** preserve the backend UUID
+on the offline v3 value. If you need premium bypass **and** backend plugin compatibility based on
+offline UUIDs, use the AuthMe proxy plugin flow above instead.
+
---
## Configuration reference
@@ -146,10 +194,14 @@ settings:
# Enable premium mode: players with an official Minecraft account
# can skip password authentication.
# Verification method is chosen automatically:
- # - online-mode=true: Bukkit already has the Mojang UUID; no PacketEvents needed.
- # - offline-mode + proxy: set Hooks.bungeecord=true; UUID is forwarded by proxy.
- # - offline-mode, no proxy: PacketEvents required for cryptographic verification.
- # Without PacketEvents, premium auto-login is disabled (fail closed).
+ # - direct offline-mode backend: PacketEvents verifies the Mojang session.
+ # - behind AuthMe Velocity/Bungee proxy: the proxy verifies premium players
+ # and sends a signed premium claim.
+ # premium.keepOfflineUuidCompatibility=false (default) keeps the Mojang UUID.
+ # premium.keepOfflineUuidCompatibility=true keeps the backend offline UUID.
+ # - plain online-mode proxy forwarding also forwards the Mojang UUID, but
+ # without the AuthMe proxy plugin's signed premium flow.
+ # If verification is unavailable, premium auto-login is disabled (fail closed).
# Players must use /premium to opt in.
enablePremium: false
```
@@ -171,7 +223,9 @@ settings:
**Q: Can I use this on an online-mode server?**
A: Online-mode servers already enforce Mojang authentication at the server level — you do not
-need AuthMe's premium bypass at all. AuthMe is primarily designed for offline-mode servers.
+need AuthMe's premium bypass at all. AuthMe is primarily designed for offline-mode servers, or
+for proxy setups where the proxy verifies premium identity but the backend still keeps offline
+UUID semantics.
**Q: What happens if Mojang's session server is down?**
A: The Minecraft client must contact `sessionserver.mojang.com/session/minecraft/join` before
@@ -187,6 +241,8 @@ player name, the player may need to be re-enrolled with `/authme premium` after
depending on your account-linking configuration.
**Q: Can a non-premium player impersonate a premium player?**
-A: No. The verify-token check and the `hasJoined` call together ensure that only a client
-which actually holds the Mojang session for that account can complete the handshake. An
-attacker who merely knows the username cannot forge the encrypted shared secret.
+A: No. In direct mode, the verify-token check and the `hasJoined` call ensure that only a client
+which actually holds the Mojang session for that account can complete the handshake. Behind a
+proxy, the backend additionally requires a valid HMAC-signed `perform.login` payload and refuses
+any premium UUID that does not match stored or pending premium state. An attacker who merely knows
+the username cannot forge those checks.
diff --git a/docs/proxies/bungee/config.yml b/docs/proxies/bungee/config.yml
index 113ce1666..03afb8f22 100644
--- a/docs/proxies/bungee/config.yml
+++ b/docs/proxies/bungee/config.yml
@@ -1,5 +1,5 @@
# AUTO-GENERATED FILE! Do not edit this directly
-# File auto-generated on Wed May 06 00:22:42 CEST 2026. See authme-tools/src/test/java/tools/docs/proxies/bungee/config.tpl.yml
+# File auto-generated on Wed May 13 01:10:04 CEST 2026. See authme-tools/src/test/java/tools/docs/proxies/bungee/config.tpl.yml
#
# Reference configuration for the native AuthMe Bungee proxy plugin.
# The live file is created in plugins/AuthMeBungee/config.yml.
@@ -43,4 +43,9 @@ loginServer: ''
# Generated automatically on first start — copy this value to the Hooks.proxySharedSecret
# setting of every backend server running AuthMe.
proxySharedSecret: ''
+premium:
+ # Keep verified premium players on the backend offline UUID for plugin compatibility.
+ # When false, premium players keep their Mojang UUID on the backend instead.
+ # False uses Bungee's local online-mode handshake path and does not require PacketEvents.
+ keepOfflineUuidCompatibility: false
diff --git a/docs/proxies/configuration.md b/docs/proxies/configuration.md
index c8c7ef04c..5cc3aa6ef 100644
--- a/docs/proxies/configuration.md
+++ b/docs/proxies/configuration.md
@@ -77,8 +77,8 @@ Hooks:
bungeecord: true
# Shared secret — must match proxySharedSecret in the proxy plugin config.
- # Leave empty only for testing; always set in production.
- proxySharedSecret: ""
+ # Must not be empty; see "Setting up the shared secret" below.
+ proxySharedSecret: "copy-from-proxy-config"
# Optional: redirect players to this backend after login/register.
# Leave empty to keep players on the current server.
@@ -114,7 +114,7 @@ The shared secret prevents malicious backend servers (or other plugins) from for
2. Copy that value to `Hooks.proxySharedSecret` in the AuthMe `config.yml` of **every backend server**.
3. Restart all backend servers (or run `/authme reload`).
-If `Hooks.proxySharedSecret` is left empty on a backend, HMAC verification still runs — using an empty string as the key. This means the backend will only accept `perform.login` messages signed with the same empty key, which provides no real security. Always set a real secret in production.
+If `Hooks.proxySharedSecret` is left empty on a backend, all `perform.login` messages are rejected with a warning — the backend logs `Hooks.proxySharedSecret is not configured` and refuses to auto-authenticate the player. Always copy the proxy-generated secret to every backend before enabling auto-login.
---
@@ -209,6 +209,29 @@ chatRequiresAuth: true
---
+### Premium UUID compatibility — `premium.keepOfflineUuidCompatibility`
+
+Controls how the AuthMe proxy plugins expose **premium** players to backend servers.
+
+```yaml
+premium:
+ keepOfflineUuidCompatibility: false # default
+```
+
+- `false` (**default**) — premium players keep their **Mojang UUID v4** on the backend
+- `true` — premium players keep the backend **offline UUID v3** for plugin compatibility
+
+Behavior by proxy:
+
+| Proxy | `false` | `true` |
+|---|---|---|
+| Velocity | Native per-player online mode, Mojang UUID forwarded | Same native login flow, but the final profile is rewritten back to offline UUID |
+| BungeeCord / Waterfall | Local per-player online-mode handshake, Mojang UUID forwarded | PacketEvents login-phase verification, then resume offline login |
+
+PacketEvents is only required on the **Bungee** proxy when this setting is `true`.
+
+---
+
## Multi-proxy setup
Some networks run multiple proxy instances behind a load balancer (e.g., two Velocity nodes behind HAProxy or TCPShield). The following explains what works automatically and what requires manual coordination.
@@ -309,6 +332,9 @@ Changes to the proxy config file can be applied without restarting the proxy:
- Check `Hooks.bungeecord: true` in the backend AuthMe config.
- Check `bungeecord: true` in `spigot.yml` (or Paper equivalent) — plugin messaging won't work without it.
+**`Hooks.proxySharedSecret is not configured`** in backend logs
+- `Hooks.proxySharedSecret` is empty in the backend `config.yml`. Copy the value from the proxy config and run `/authme reload`.
+
**`Rejected perform.login for : invalid HMAC`** in backend logs
- The `proxySharedSecret` on this backend does not match the one in the proxy config. Copy it again and reload.
diff --git a/docs/proxies/velocity/config.yml b/docs/proxies/velocity/config.yml
index b522874de..1d4dd9964 100644
--- a/docs/proxies/velocity/config.yml
+++ b/docs/proxies/velocity/config.yml
@@ -1,5 +1,5 @@
# AUTO-GENERATED FILE! Do not edit this directly
-# File auto-generated on Wed May 06 00:22:42 CEST 2026. See authme-tools/src/test/java/tools/docs/proxies/velocity/config.tpl.yml
+# File auto-generated on Wed May 13 01:10:04 CEST 2026. See authme-tools/src/test/java/tools/docs/proxies/velocity/config.tpl.yml
#
# Reference configuration for the native AuthMe Velocity proxy plugin.
# The live file is created in plugins/authmevelocity/config.yml.
@@ -43,4 +43,9 @@ loginServer: ''
# Generated automatically on first start — copy this value to the proxySharedSecret
# setting of every backend server running AuthMe.
proxySharedSecret: ''
+premium:
+ # Keep verified premium players on the backend offline UUID for plugin compatibility.
+ # When false, premium players keep their Mojang UUID on the backend instead.
+ # Velocity can do both modes natively; false is the default.
+ keepOfflineUuidCompatibility: false
diff --git a/pom.xml b/pom.xml
index a6dd20a34..d2cb082bb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -105,6 +105,7 @@
3.4.0
1.21-R0.4
+ 2.12.1
1.16.5-R0.1-SNAPSHOT
2.25.4
@@ -628,7 +629,19 @@
com.github.retrooper
packetevents-spigot
- 2.12.1
+ ${dependencies.packetevents.version}
+ provided
+
+
+ com.github.retrooper
+ packetevents-bungeecord
+ ${dependencies.packetevents.version}
+ provided
+
+
+ com.github.retrooper
+ packetevents-velocity
+ ${dependencies.packetevents.version}
provided