From 702aad77d5432359db85b2c8d4ad3c2f4735e25b Mon Sep 17 00:00:00 2001 From: SyntaxDevTeam Date: Thu, 1 Oct 2026 21:27:11 +0200 Subject: [PATCH] Add Microsoft premium accounts and authenticated server connections --- README.md | 8 ++ .../craftconnect/CraftConnectApp.kt | 29 ++++- .../craftconnect/CraftConnectApplication.kt | 24 +++- .../craftconnect/MainActivity.kt | 8 +- .../SharedPreferencesAccountRepository.kt | 15 ++- .../craftconnect/data/auth/AuthHttpClient.kt | 63 ++++++++++ .../data/auth/KeystoreRefreshTokenStore.kt | 59 +++++++++ .../data/auth/MicrosoftAccountManager.kt | 77 ++++++++++++ .../data/auth/MicrosoftMinecraftAuthApi.kt | 110 +++++++++++++++++ .../data/session/DefaultSessionManager.kt | 24 +++- .../data/session/ForegroundSessionManager.kt | 7 ++ .../domain/account/AccountRepository.kt | 1 + .../domain/auth/MicrosoftAuthentication.kt | 41 +++++++ .../domain/session/SessionManager.kt | 13 +- .../protocol/MinecraftConnection.kt | 11 +- .../ModernOfflineMinecraftConnection.kt | 69 +++++++++-- .../protocol/modern/OnlineLoginEncryption.kt | 40 ++++++ .../ui/components/MicrosoftSignInCard.kt | 60 +++++++++ .../craftconnect/ui/screens/AccountsScreen.kt | 44 ++++--- .../craftconnect/ui/screens/ServersScreen.kt | 21 ++-- app/src/main/res/values/strings.xml | 26 +++- .../data/auth/MicrosoftAccountManagerTest.kt | 95 +++++++++++++++ .../auth/MicrosoftMinecraftAuthApiTest.kt | 103 ++++++++++++++++ .../data/session/DefaultSessionManagerTest.kt | 32 +++++ .../modern/ModernPremiumConnectionTest.kt | 115 ++++++++++++++++++ docs/MICROSOFT_ACCOUNTS.md | 78 ++++++++++++ 26 files changed, 1110 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/AuthHttpClient.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/KeystoreRefreshTokenStore.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftAccountManager.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftMinecraftAuthApi.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/domain/auth/MicrosoftAuthentication.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/OnlineLoginEncryption.kt create mode 100644 app/src/main/java/pl/syntaxdevteam/craftconnect/ui/components/MicrosoftSignInCard.kt create mode 100644 app/src/test/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftAccountManagerTest.kt create mode 100644 app/src/test/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftMinecraftAuthApiTest.kt create mode 100644 app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernPremiumConnectionTest.kt create mode 100644 docs/MICROSOFT_ACCOUNTS.md diff --git a/README.md b/README.md index 47a16ad..ab9074a 100644 --- a/README.md +++ b/README.md @@ -103,3 +103,11 @@ Failures include the last 12 inbound/outbound packet IDs, prefixed `i`/`o`, toge with the stage and protocol. The trace contains no packet payloads or credentials. These changes address missing configuration replies; live connectivity to MoonVale has not been verified from the development environment. + +## Microsoft / premium accounts + +Account manager supports Microsoft sign-in in the system browser, verified Java +Edition profiles, secure session storage and refresh, and premium account +selection for authenticated server connections. Publisher setup in Entra and +Mojang AppID approval are required before live use. See +[Microsoft accounts: setup, security and current limits](docs/MICROSOFT_ACCOUNTS.md). diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt index 4cd012c..8d85f27 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt @@ -1,5 +1,9 @@ package pl.syntaxdevteam.craftconnect +import pl.syntaxdevteam.craftconnect.domain.auth.AuthProblem +import pl.syntaxdevteam.craftconnect.data.auth.MicrosoftAccountManager +import pl.syntaxdevteam.craftconnect.ui.components.authProblemText + import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding @@ -48,6 +52,7 @@ fun CraftConnectApp( accountRepository: AccountRepository, sessionScope: CoroutineScope, onBeforeConnect: () -> Unit, + microsoftAccounts: MicrosoftAccountManager, ) { val sessionManager = remember { sessionManagerFactory.create() } var destination by rememberSaveable { @@ -61,6 +66,8 @@ fun CraftConnectApp( val chatMessages by sessionManager.chatMessages.collectAsState() val savedServers by serverRepository.servers.collectAsState() val savedAccounts by accountRepository.accounts.collectAsState() + val microsoftState by microsoftAccounts.state.collectAsState() + val context = androidx.compose.ui.platform.LocalContext.current val coroutineScope = sessionScope LaunchedEffect(sessionManager) { @@ -72,7 +79,10 @@ fun CraftConnectApp( if (previousState != ConnectionState.CONNECTED) destination = AppDestination.Chat } connectionError = snapshot.lastError?.let { error -> - listOfNotNull(error.serverMessage, error.diagnosticCode).distinct().joinToString("\n") + AuthProblem.entries.firstOrNull { + error.diagnosticCode == "premium_${it.name.lowercase()}" + }?.let { context.getString(authProblemText(it)) } + ?: listOfNotNull(error.serverMessage, error.diagnosticCode).distinct().joinToString("\n") } if (snapshot.connectionState != ConnectionState.CONNECTED) serverDialog = null if (snapshot.connectionState == ConnectionState.FAILED) { @@ -118,17 +128,20 @@ fun CraftConnectApp( }, onUpdate = serverRepository::update, onDelete = serverRepository::delete, - onConnect = { server, username -> + onConnect = { server, account -> onBeforeConnect() coroutineScope.launch { sessionManager.disconnect() val manager = sessionManagerFactory.create() connectionState = ConnectionState.CONNECTING connectionError = null - manager.connect(server, username) + manager.connect(server, account) connectionState = manager.session.value.connectionState connectionError = manager.session.value.lastError?.let { error -> - listOfNotNull(error.serverMessage, error.diagnosticCode).distinct().joinToString("\n") + AuthProblem.entries.firstOrNull { + error.diagnosticCode == "premium_${it.name.lowercase()}" + }?.let { context.getString(authProblemText(it)) } + ?: listOfNotNull(error.serverMessage, error.diagnosticCode).distinct().joinToString("\n") } if (connectionState == ConnectionState.CONNECTED) { activeServer = server.copy(online = true) @@ -148,7 +161,13 @@ fun CraftConnectApp( accounts = savedAccounts, onCreate = accountRepository::createOffline, onUpdate = accountRepository::update, - onDelete = accountRepository::delete, + onDelete = { id -> coroutineScope.launch { + if (id == "microsoft:${sessionManager.session.value.uuid?.replace("-", "")}") sessionManager.disconnect() + microsoftAccounts.delete(id) + } }, + microsoftState = microsoftState, + onMicrosoftSignIn = microsoftAccounts::start, + onCancelSignIn = microsoftAccounts::cancel, ) AppDestination.Settings -> SettingsScreen() } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApplication.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApplication.kt index 4024ac6..7eb9128 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApplication.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApplication.kt @@ -1,5 +1,12 @@ package pl.syntaxdevteam.craftconnect +import pl.syntaxdevteam.craftconnect.data.account.SharedPreferencesAccountRepository +import pl.syntaxdevteam.craftconnect.data.auth.KeystoreRefreshTokenStore +import pl.syntaxdevteam.craftconnect.data.auth.MicrosoftAccountManager +import pl.syntaxdevteam.craftconnect.data.auth.MicrosoftMinecraftAuthApi +import pl.syntaxdevteam.craftconnect.data.session.DefaultSessionManager +import pl.syntaxdevteam.craftconnect.protocol.modern.ModernOfflineMinecraftConnection + import android.app.Application import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -11,6 +18,21 @@ import pl.syntaxdevteam.craftconnect.domain.session.SessionManagerFactory /** One protocol session per process, independent of Activity/Compose recreation. */ class CraftConnectApplication : Application() { val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - val sessionManager by lazy { ForegroundSessionManager(AndroidSessionServiceController(this), sessionScope) } + val accountRepository by lazy { + SharedPreferencesAccountRepository(getSharedPreferences("saved_accounts", MODE_PRIVATE)) + } + private val microsoftApi by lazy { + MicrosoftMinecraftAuthApi("931c77bd-a884-457d-9dbd-da85c2fca433") + } + val microsoftAccounts by lazy { + MicrosoftAccountManager(accountRepository, microsoftApi, + KeystoreRefreshTokenStore(this), sessionScope) + } + val sessionManager by lazy { + ForegroundSessionManager(AndroidSessionServiceController(this), sessionScope, + DefaultSessionManager( + ModernOfflineMinecraftConnection(joinSession = microsoftApi::join), + premiumIdentity = microsoftAccounts::identity)) + } val sessionManagerFactory = SessionManagerFactory { sessionManager } } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt index f1f9f7d..2e1a2a0 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/MainActivity.kt @@ -10,7 +10,6 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import org.xbill.DNS.config.AndroidResolverConfigProvider import pl.syntaxdevteam.craftconnect.data.server.SharedPreferencesServerRepository -import pl.syntaxdevteam.craftconnect.data.account.SharedPreferencesAccountRepository import pl.syntaxdevteam.craftconnect.ui.theme.CraftConnectTheme class MainActivity : ComponentActivity() { @@ -28,19 +27,16 @@ class MainActivity : ComponentActivity() { private val serverRepository by lazy { SharedPreferencesServerRepository(getSharedPreferences("saved_servers", MODE_PRIVATE)) } - private val accountRepository by lazy { - SharedPreferencesAccountRepository(getSharedPreferences("saved_accounts", MODE_PRIVATE)) - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) AndroidResolverConfigProvider.setContext(applicationContext) setContent { CraftConnectTheme { CraftConnectApp( - (application as CraftConnectApplication).sessionManagerFactory, serverRepository, accountRepository, + (application as CraftConnectApplication).sessionManagerFactory, serverRepository, (application as CraftConnectApplication).accountRepository, (application as CraftConnectApplication).sessionScope, ::requestConnectionNotification, + (application as CraftConnectApplication).microsoftAccounts, ) } } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepository.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepository.kt index 2c779c1..02ce602 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepository.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/account/SharedPreferencesAccountRepository.kt @@ -21,7 +21,7 @@ class SharedPreferencesAccountRepository internal constructor( @Synchronized override fun createOffline(username: String): AccountProfile { val normalized = validateUsername(username) - require(mutableAccounts.value.none { it.username.equals(normalized, ignoreCase = true) }) { + require(mutableAccounts.value.none { it.type == AccountType.OFFLINE && it.username.equals(normalized, ignoreCase = true) }) { "Account username already exists" } return AccountProfile(createId(), normalized, AccountType.OFFLINE).also { @@ -29,12 +29,21 @@ class SharedPreferencesAccountRepository internal constructor( } } + @Synchronized + override fun saveMicrosoft(username: String, uuid: String): AccountProfile { + val normalized = uuid.replace("-", "").lowercase() + require(Regex("[a-f0-9]{32}").matches(normalized)) + val profile = AccountProfile("microsoft:$normalized", validateUsername(username), AccountType.MICROSOFT) + publish(mutableAccounts.value.filterNot { it.id == profile.id } + profile) + return profile + } + @Synchronized override fun update(account: AccountProfile) { - require(account.type == AccountType.OFFLINE) { "Microsoft accounts cannot be edited yet" } + require(account.type == AccountType.OFFLINE && mutableAccounts.value.none { it.id == account.id && it.type != AccountType.OFFLINE }) { "Microsoft identity is managed by authentication" } val normalized = account.copy(username = validateUsername(account.username)) require(mutableAccounts.value.none { - it.id != normalized.id && it.username.equals(normalized.username, ignoreCase = true) + it.type == AccountType.OFFLINE && it.id != normalized.id && it.username.equals(normalized.username, ignoreCase = true) }) { "Account username already exists" } if (mutableAccounts.value.none { it.id == normalized.id }) return publish(mutableAccounts.value.map { if (it.id == normalized.id) normalized else it }) diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/AuthHttpClient.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/AuthHttpClient.kt new file mode 100644 index 0000000..b7d75fb --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/AuthHttpClient.kt @@ -0,0 +1,63 @@ +package pl.syntaxdevteam.craftconnect.data.auth + +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import pl.syntaxdevteam.craftconnect.domain.auth.AuthenticationException +import pl.syntaxdevteam.craftconnect.domain.auth.AuthProblem + +internal class AuthResponse(val status: Int, val body: JSONObject) +internal fun interface AuthTransport { + suspend fun request(url: String, body: String?, contentType: String, bearer: String?): AuthResponse +} + +/** Fixed HTTPS service endpoints only; never follow a redirect with credentials. */ +internal class AuthHttpClient : AuthTransport { + override suspend fun request(url: String, body: String?, contentType: String, bearer: String?): AuthResponse = withContext(Dispatchers.IO) { + val endpoint = URL(url) + require(endpoint.protocol == "https" && endpoint.host in HOSTS) + val connection = endpoint.openConnection() as HttpURLConnection + try { + connection.instanceFollowRedirects = false + connection.connectTimeout = 15_000 + connection.readTimeout = 20_000 + connection.requestMethod = if (body == null) "GET" else "POST" + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty("Content-Type", contentType) + if (endpoint.host.endsWith(".xboxlive.com")) connection.setRequestProperty("x-xbl-contract-version", "1") + bearer?.let { connection.setRequestProperty("Authorization", "Bearer $it") } + if (body != null) { + connection.doOutput = true + connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val bytes = stream?.use { stream -> + val bytes = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (bytes.size() <= 1_048_576) { + val count = stream.read(buffer) + if (count < 0) break + bytes.write(buffer, 0, count) + } + bytes.toByteArray() } ?: byteArrayOf() + if (bytes.size > 1_048_576) throw AuthenticationException(AuthProblem.INVALID_RESPONSE) + val json = if (bytes.isEmpty()) JSONObject() else try { JSONObject(String(bytes, Charsets.UTF_8)) } + catch (_: Exception) { JSONObject() } + AuthResponse(status, json) + } catch (failure: java.io.IOException) { + throw AuthenticationException(AuthProblem.NETWORK) + } finally { connection.disconnect() } + } + + private companion object { + val HOSTS = setOf("login.microsoftonline.com", "user.auth.xboxlive.com", "xsts.auth.xboxlive.com", "api.minecraftservices.com", "sessionserver.mojang.com") + } +} + +internal fun form(vararg fields: Pair): String = fields.joinToString("&") { + URLEncoder.encode(it.first, "UTF-8") + "=" + URLEncoder.encode(it.second, "UTF-8") +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/KeystoreRefreshTokenStore.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/KeystoreRefreshTokenStore.kt new file mode 100644 index 0000000..7aa3284 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/KeystoreRefreshTokenStore.kt @@ -0,0 +1,59 @@ +package pl.syntaxdevteam.craftconnect.data.auth + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.AtomicFile +import java.io.File +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import pl.syntaxdevteam.craftconnect.domain.auth.* + +/** Device-bound encrypted refresh tokens, excluded from cloud backup and device transfer. */ +class KeystoreRefreshTokenStore(context: Context) : RefreshTokenStore { + private val directory = File(context.noBackupFilesDir, "microsoft_accounts").apply { mkdirs() } + private fun file(id: String): AtomicFile { + require(Regex("microsoft:[a-f0-9]{32}").matches(id)) + return AtomicFile(File(directory, id.removePrefix("microsoft:"))) + } + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build()) + }.generateKey() + } + @Synchronized + override fun read(id: String): String? { + val file = file(id) + if (!file.baseFile.exists()) return null + return try { + val bytes = file.readFully() + require(bytes.size in 29..65536) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12))) + cipher.updateAAD(id.toByteArray(Charsets.UTF_8)) + String(cipher.doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8) + } catch (_: Exception) { throw AuthenticationException(AuthProblem.REAUTHENTICATE) } + } + @Synchronized + override fun write(id: String, token: String) { + try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + cipher.updateAAD(id.toByteArray(Charsets.UTF_8)) + val bytes = cipher.iv + cipher.doFinal(token.toByteArray(Charsets.UTF_8)) + val target = file(id) + val stream = target.startWrite() + try { stream.write(bytes); target.finishWrite(stream) } + catch (failure: Exception) { target.failWrite(stream); throw failure } + } catch (_: Exception) { throw AuthenticationException(AuthProblem.STORAGE) } + } + @Synchronized + override fun delete(id: String) { file(id).delete() } + private companion object { const val ALIAS = "craftconnect_microsoft_refresh_v1" } +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftAccountManager.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftAccountManager.kt new file mode 100644 index 0000000..68098d0 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftAccountManager.kt @@ -0,0 +1,77 @@ +package pl.syntaxdevteam.craftconnect.data.auth + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pl.syntaxdevteam.craftconnect.domain.account.AccountRepository +import pl.syntaxdevteam.craftconnect.domain.auth.* +import pl.syntaxdevteam.craftconnect.domain.model.AccountProfile +import pl.syntaxdevteam.craftconnect.domain.model.AccountType + +/** Process-scoped authorization survives rotation; UI never receives access/refresh tokens. */ +class MicrosoftAccountManager( + private val accounts: AccountRepository, + private val api: MicrosoftAuthApi, + private val tokens: RefreshTokenStore, + private val scope: CoroutineScope, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val mutableState = MutableStateFlow(MicrosoftSignInState.Idle) + val state = mutableState.asStateFlow() + private var signIn: Job? = null + private val mutex = Mutex() + private val sessions = mutableMapOf() + + fun start() { + if (signIn?.isActive == true) return + mutableState.value = MicrosoftSignInState.Starting + signIn = scope.launch { + try { + val authorization = api.begin() + mutableState.value = MicrosoftSignInState.Waiting(authorization.userCode, authorization.verificationUri) + val microsoft = api.awaitTokens(authorization) + val minecraft = api.minecraft(microsoft.accessToken) + ensureActive() + mutex.withLock { + val id = "microsoft:${minecraft.identity.uuid}" + tokens.write(id, microsoft.refreshToken) + accounts.saveMicrosoft(minecraft.identity.username, minecraft.identity.uuid) + sessions[id] = minecraft + } + mutableState.value = MicrosoftSignInState.Success(minecraft.identity.username) + } catch (cancelled: CancellationException) { throw cancelled } + catch (failure: AuthenticationException) { mutableState.value = MicrosoftSignInState.Failed(failure.problem) } + catch (_: Exception) { mutableState.value = MicrosoftSignInState.Failed(AuthProblem.INVALID_RESPONSE) } + } + } + + fun cancel() { + signIn?.cancel() + signIn = null + mutableState.value = MicrosoftSignInState.Idle + } + + suspend fun identity(account: AccountProfile): MinecraftIdentity = mutex.withLock { + if (account.type != AccountType.MICROSOFT || accounts.accounts.value.none { it.id == account.id }) { + throw AuthenticationException(AuthProblem.REAUTHENTICATE) + } + sessions[account.id]?.takeIf { it.expiresAt > clock() + 120_000 }?.let { return@withLock it.identity } + val stored = tokens.read(account.id) ?: throw AuthenticationException(AuthProblem.REAUTHENTICATE) + val refreshed = api.refresh(stored) + // Save rotations even when the next service is temporarily unavailable. + tokens.write(account.id, refreshed.refreshToken) + val verified = api.minecraft(refreshed.accessToken) + if ("microsoft:${verified.identity.uuid}" != account.id) throw AuthenticationException(AuthProblem.REAUTHENTICATE) + accounts.saveMicrosoft(verified.identity.username, verified.identity.uuid) + sessions[account.id] = verified + verified.identity + } + + suspend fun delete(id: String) = mutex.withLock { + if (id.startsWith("microsoft:")) tokens.delete(id) + sessions.remove(id) + accounts.delete(id) + } +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftMinecraftAuthApi.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftMinecraftAuthApi.kt new file mode 100644 index 0000000..e54fc27 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftMinecraftAuthApi.kt @@ -0,0 +1,110 @@ +package pl.syntaxdevteam.craftconnect.data.auth + +import kotlinx.coroutines.delay +import org.json.JSONArray +import org.json.JSONObject +import pl.syntaxdevteam.craftconnect.domain.auth.* + +class MicrosoftMinecraftAuthApi internal constructor( + private val clientId: String, + private val http: AuthTransport, + private val clock: () -> Long = System::currentTimeMillis, + private val pause: suspend (Long) -> Unit = { delay(it) }, +) : MicrosoftAuthApi { + constructor(clientId: String) : this(clientId, AuthHttpClient()) + + override suspend fun begin(): DeviceAuthorization { + val body = checked(http.request("$AUTH/devicecode", form("client_id" to clientId, "scope" to SCOPE), FORM, null), AuthProblem.CONFIGURATION) + val uri = body.getString("verification_uri") + // A provider response cannot direct users to a third-party credential collection page. + val parsed = java.net.URI(uri) + if (parsed.scheme != "https" || parsed.host !in setOf("microsoft.com", "www.microsoft.com", "login.microsoftonline.com")) { + throw AuthenticationException(AuthProblem.INVALID_RESPONSE) + } + return DeviceAuthorization(body.getString("device_code"), body.getString("user_code"), uri, + clock() + body.getLong("expires_in").coerceIn(1, 1800) * 1000, + body.optLong("interval", 5).coerceIn(1, 60) * 1000) + } + + override suspend fun awaitTokens(authorization: DeviceAuthorization): MicrosoftTokens { + var interval = authorization.intervalMillis + while (clock() < authorization.expiresAt) { + pause(interval) + if (clock() >= authorization.expiresAt) break + val response = http.request("$AUTH/token", form("client_id" to clientId, + "grant_type" to "urn:ietf:params:oauth:grant-type:device_code", "device_code" to authorization.deviceCode), FORM, null) + if (response.status in 200..299) return tokens(response.body) + when (response.body.optString("error")) { + "authorization_pending" -> Unit + "slow_down" -> interval += 5000 + "authorization_declined", "access_denied" -> throw AuthenticationException(AuthProblem.DECLINED) + "expired_token", "bad_verification_code" -> throw AuthenticationException(AuthProblem.EXPIRED) + else -> { checked(response, AuthProblem.CONFIGURATION) } + } + } + throw AuthenticationException(AuthProblem.EXPIRED) + } + + override suspend fun refresh(refreshToken: String): MicrosoftTokens = tokens(checked( + http.request("$AUTH/token", form("client_id" to clientId, "grant_type" to "refresh_token", + "refresh_token" to refreshToken, "scope" to SCOPE), FORM, null), AuthProblem.REAUTHENTICATE), refreshToken) + + override suspend fun minecraft(accessToken: String): VerifiedMinecraftAccount { + val user = json("https://user.auth.xboxlive.com/user/authenticate", JSONObject() + .put("Properties", JSONObject().put("AuthMethod", "RPS").put("SiteName", "user.auth.xboxlive.com").put("RpsTicket", "d=$accessToken")) + .put("RelyingParty", "http://auth.xboxlive.com").put("TokenType", "JWT"), AuthProblem.XBOX_ACCOUNT) + val xsts = json("https://xsts.auth.xboxlive.com/xsts/authorize", JSONObject() + .put("Properties", JSONObject().put("SandboxId", "RETAIL").put("UserTokens", JSONArray().put(user.getString("Token")))) + .put("RelyingParty", "rp://api.minecraftservices.com/").put("TokenType", "JWT"), AuthProblem.XBOX_ACCOUNT) + fun hash(body: JSONObject) = body.getJSONObject("DisplayClaims").getJSONArray("xui").getJSONObject(0).getString("uhs") + if (hash(user) != hash(xsts)) throw AuthenticationException(AuthProblem.INVALID_RESPONSE) + val login = json("$MC/authentication/login_with_xbox", JSONObject() + .put("identityToken", "XBL3.0 x=${hash(xsts)};${xsts.getString("Token")}"), AuthProblem.APP_APPROVAL) + val token = login.getString("access_token") + val entitlements = checked(http.request("$MC/entitlements/mcstore", null, JSON, token), AuthProblem.NO_LICENSE) + val items = entitlements.getJSONArray("items") + if ((0 until items.length()).none { items.getJSONObject(it).optString("name") in setOf("game_minecraft", "product_minecraft") }) { + throw AuthenticationException(AuthProblem.NO_LICENSE) + } + val profile = checked(http.request("$MC/minecraft/profile", null, JSON, token), AuthProblem.NO_PROFILE) + val id = profile.getString("id") + val name = profile.getString("name") + if (!Regex("[a-fA-F0-9]{32}").matches(id) || !Regex("[A-Za-z0-9_]{3,16}").matches(name)) { + throw AuthenticationException(AuthProblem.INVALID_RESPONSE) + } + return VerifiedMinecraftAccount(MinecraftIdentity(name, id.lowercase(), token), + clock() + login.getLong("expires_in").coerceIn(1, 86400) * 1000) + } + + override suspend fun join(identity: MinecraftIdentity, serverHash: String) { + val response = http.request("https://sessionserver.mojang.com/session/minecraft/join", JSONObject() + .put("accessToken", identity.accessToken).put("selectedProfile", identity.uuid.replace("-", "")) + .put("serverId", serverHash).toString(), JSON, null) + if (response.status != 204) { + checked(response, AuthProblem.REAUTHENTICATE) + throw AuthenticationException(AuthProblem.INVALID_RESPONSE) + } + } + + private suspend fun json(url: String, body: JSONObject, problem: AuthProblem) = checked(http.request(url, body.toString(), JSON, null), problem) + private fun checked(response: AuthResponse, fallback: AuthProblem): JSONObject { + if (response.status !in 200..299) throw AuthenticationException(when { + response.status == 429 || response.status >= 500 -> AuthProblem.NETWORK + response.body.optString("error") == "invalid_grant" -> AuthProblem.REAUTHENTICATE + else -> fallback + }) + return response.body + } + private fun tokens(body: JSONObject, previousRefresh: String? = null): MicrosoftTokens { + val refresh = body.optString("refresh_token").ifBlank { previousRefresh.orEmpty() } + if (refresh.isBlank()) throw AuthenticationException(AuthProblem.INVALID_RESPONSE) + return MicrosoftTokens(body.getString("access_token"), refresh) + } + private companion object { + const val AUTH = "https://login.microsoftonline.com/consumers/oauth2/v2.0" + const val MC = "https://api.minecraftservices.com" + const val SCOPE = "XboxLive.signin offline_access" + const val FORM = "application/x-www-form-urlencoded" + const val JSON = "application/json" + } +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt index 20809e8..6257bec 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManager.kt @@ -1,5 +1,12 @@ package pl.syntaxdevteam.craftconnect.data.session +import pl.syntaxdevteam.craftconnect.domain.auth.AuthProblem.CONFIGURATION +import pl.syntaxdevteam.craftconnect.domain.auth.AuthenticationException +import pl.syntaxdevteam.craftconnect.domain.auth.MinecraftIdentity +import pl.syntaxdevteam.craftconnect.domain.model.AccountProfile +import pl.syntaxdevteam.craftconnect.domain.model.AccountType.MICROSOFT +import pl.syntaxdevteam.craftconnect.protocol.ConnectedSession + import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,6 +37,9 @@ class DefaultSessionManager( private val connection: MinecraftConnection, private val clock: () -> Long = System::currentTimeMillis, private val observerScope: CoroutineScope = CoroutineScope(Dispatchers.Default), + private val premiumIdentity: suspend (AccountProfile) -> MinecraftIdentity = { + throw AuthenticationException(CONFIGURATION) + }, ) : SessionManager { private val operationMutex = Mutex() private var failureObserver: Job? = null @@ -42,7 +52,15 @@ class DefaultSessionManager( override val players = connection.players override val dialogEvents: Flow = connection.dialogEvents - override suspend fun connect(server: ServerProfile, username: String) = operationMutex.withLock { + override suspend fun connect(server: ServerProfile, username: String) = connectUsing(server) { connection.connect(server, username) } + + override suspend fun connect(server: ServerProfile, account: AccountProfile) = + connectUsing(server) { + if (account.type == MICROSOFT) connection.connect(server, premiumIdentity(account)) + else connection.connect(server, account.username) + } + + private suspend fun connectUsing(server: ServerProfile, login: suspend () -> ConnectedSession) = operationMutex.withLock { if (session.value.connectionState != ConnectionState.DISCONNECTED && session.value.connectionState != ConnectionState.FAILED ) { @@ -52,7 +70,7 @@ class DefaultSessionManager( failureObserver?.cancel() update(SessionSnapshot(connectionState = ConnectionState.CONNECTING, server = server)) try { - val connected = connection.connect(server, username) + val connected = login() update( session.value.copy( connectionState = ConnectionState.CONNECTED, @@ -72,6 +90,8 @@ class DefaultSessionManager( } catch (cancelled: CancellationException) { update(SessionSnapshot()) throw cancelled + } catch (failure: AuthenticationException) { + fail(SessionError.Authentication("premium_${failure.problem.name.lowercase()}")) } catch (failure: MinecraftConnectionException) { fail(failure.toSessionError()) } catch (_: Exception) { diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/ForegroundSessionManager.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/ForegroundSessionManager.kt index 55b9b01..24d9d65 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/ForegroundSessionManager.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/data/session/ForegroundSessionManager.kt @@ -1,5 +1,7 @@ package pl.syntaxdevteam.craftconnect.data.session +import pl.syntaxdevteam.craftconnect.domain.model.AccountProfile + import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -28,6 +30,11 @@ class ForegroundSessionManager( delegate.connect(server, username) } + override suspend fun connect(server: ServerProfile, account: AccountProfile) { + service.start() + delegate.connect(server, account) + } + override suspend fun disconnect() { try { delegate.disconnect() } finally { service.stop() } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/account/AccountRepository.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/account/AccountRepository.kt index 3909704..79cb5a6 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/account/AccountRepository.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/account/AccountRepository.kt @@ -7,6 +7,7 @@ interface AccountRepository { val accounts: StateFlow> fun createOffline(username: String): AccountProfile + fun saveMicrosoft(username: String, uuid: String): AccountProfile fun update(account: AccountProfile) fun delete(id: String) } diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/auth/MicrosoftAuthentication.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/auth/MicrosoftAuthentication.kt new file mode 100644 index 0000000..d369284 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/auth/MicrosoftAuthentication.kt @@ -0,0 +1,41 @@ +package pl.syntaxdevteam.craftconnect.domain.auth + +/** Credentials deliberately have no generated toString/equals or saved UI state. */ +class MinecraftIdentity(val username: String, val uuid: String, val accessToken: String) +class MicrosoftTokens(val accessToken: String, val refreshToken: String) +class VerifiedMinecraftAccount(val identity: MinecraftIdentity, val expiresAt: Long) +class DeviceAuthorization( + val deviceCode: String, + val userCode: String, + val verificationUri: String, + val expiresAt: Long, + val intervalMillis: Long, +) + +enum class AuthProblem { + NETWORK, EXPIRED, DECLINED, CONFIGURATION, APP_APPROVAL, NO_LICENSE, NO_PROFILE, + XBOX_ACCOUNT, REAUTHENTICATE, STORAGE, INVALID_RESPONSE, +} +class AuthenticationException(val problem: AuthProblem) : Exception(problem.name) + +sealed interface MicrosoftSignInState { + data object Idle : MicrosoftSignInState + data object Starting : MicrosoftSignInState + data class Waiting(val userCode: String, val verificationUri: String) : MicrosoftSignInState + data class Success(val username: String) : MicrosoftSignInState + data class Failed(val problem: AuthProblem) : MicrosoftSignInState +} + +interface MicrosoftAuthApi { + suspend fun begin(): DeviceAuthorization + suspend fun awaitTokens(authorization: DeviceAuthorization): MicrosoftTokens + suspend fun refresh(refreshToken: String): MicrosoftTokens + suspend fun minecraft(accessToken: String): VerifiedMinecraftAccount + suspend fun join(identity: MinecraftIdentity, serverHash: String) +} + +interface RefreshTokenStore { + fun read(id: String): String? + fun write(id: String, token: String) + fun delete(id: String) +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionManager.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionManager.kt index 9076b1f..49eb6d7 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionManager.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/domain/session/SessionManager.kt @@ -1,5 +1,10 @@ package pl.syntaxdevteam.craftconnect.domain.session +import pl.syntaxdevteam.craftconnect.domain.model.AccountProfile +import pl.syntaxdevteam.craftconnect.domain.model.AccountType.OFFLINE +import pl.syntaxdevteam.craftconnect.domain.model.ReceivedChatMessage +import pl.syntaxdevteam.craftconnect.domain.model.ServerPlayer + import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -7,10 +12,10 @@ import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile import pl.syntaxdevteam.craftconnect.domain.model.ServerDialogEvent interface SessionManager { - val players: kotlinx.coroutines.flow.StateFlow> + val players: kotlinx.coroutines.flow.StateFlow> get() = kotlinx.coroutines.flow.MutableStateFlow(emptyList()) - val chatMessages: kotlinx.coroutines.flow.StateFlow> + val chatMessages: kotlinx.coroutines.flow.StateFlow> get() = kotlinx.coroutines.flow.MutableStateFlow(emptyList()) val session: StateFlow @@ -18,6 +23,10 @@ interface SessionManager { val dialogEvents: Flow suspend fun connect(server: ServerProfile, username: String) + suspend fun connect(server: ServerProfile, account: AccountProfile) { + require(account.type == OFFLINE) + connect(server, account.username) + } suspend fun disconnect() suspend fun sendChat(message: String) suspend fun sendCommand(command: String) diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt index e813455..265801d 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/MinecraftConnection.kt @@ -1,5 +1,9 @@ package pl.syntaxdevteam.craftconnect.protocol +import pl.syntaxdevteam.craftconnect.domain.auth.MinecraftIdentity +import pl.syntaxdevteam.craftconnect.domain.model.ReceivedChatMessage +import pl.syntaxdevteam.craftconnect.domain.model.ServerPlayer + import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import pl.syntaxdevteam.craftconnect.domain.model.ServerProfile @@ -16,10 +20,10 @@ data class ConnectedSession( * Presentation code communicates through SessionManager instead of this type. */ interface MinecraftConnection { - val players: kotlinx.coroutines.flow.StateFlow> + val players: kotlinx.coroutines.flow.StateFlow> get() = kotlinx.coroutines.flow.MutableStateFlow(emptyList()) - val chatMessages: kotlinx.coroutines.flow.StateFlow> + val chatMessages: kotlinx.coroutines.flow.StateFlow> get() = kotlinx.coroutines.flow.MutableStateFlow(emptyList()) val dialogEvents: Flow @@ -30,6 +34,9 @@ interface MinecraftConnection { get() = emptyFlow() suspend fun connect(server: ServerProfile, username: String): ConnectedSession + suspend fun connect(server: ServerProfile, identity: MinecraftIdentity): ConnectedSession { + throw MinecraftConnectionException.Authentication("premium_protocol_unsupported") + } suspend fun disconnect() suspend fun sendChat(message: String) suspend fun sendCommand(command: String) diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnection.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnection.kt index 9167752..5e0fb4a 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnection.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernOfflineMinecraftConnection.kt @@ -1,5 +1,9 @@ package pl.syntaxdevteam.craftconnect.protocol.modern +import pl.syntaxdevteam.craftconnect.domain.auth.AuthenticationException +import pl.syntaxdevteam.craftconnect.domain.auth.MinecraftIdentity +import pl.syntaxdevteam.craftconnect.protocol.chat.ChatHistory + import pl.syntaxdevteam.craftconnect.domain.model.MinecraftVersion import java.io.ByteArrayInputStream import java.io.BufferedInputStream @@ -37,9 +41,12 @@ import pl.syntaxdevteam.craftconnect.protocol.legacy.readVarInt import pl.syntaxdevteam.craftconnect.protocol.legacy.writeProtocolString import pl.syntaxdevteam.craftconnect.protocol.legacy.writeVarInt -/** Headless offline-mode adapter for Minecraft Java 26.1–26.3. */ +/** Headless offline/online-mode adapter for Minecraft Java 26.1–26.3. */ class ModernOfflineMinecraftConnection( private val connectTimeoutMillis: Int = 15_000, + private val joinSession: suspend (MinecraftIdentity, String) -> Unit = { _, _ -> + throw MinecraftConnectionException.Authentication("premium_not_configured") + }, ) : MinecraftConnection { private var version = MinecraftVersion.JAVA_26_1 private var connectionStage = "resolve" @@ -49,7 +56,7 @@ class ModernOfflineMinecraftConnection( private val chatTypes = ServerChatTypes() private val playerList = ServerPlayerList() override val players = playerList.players - private val chatHistory = pl.syntaxdevteam.craftconnect.protocol.chat.ChatHistory() + private val chatHistory = ChatHistory() override val chatMessages = chatHistory.messages private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val visibilityCommands = JoinVisibilityCommands(scope, ::sendCommand, sendAuthenticationRequest = ::sendAuthenticationSubscription) @@ -60,8 +67,8 @@ class ModernOfflineMinecraftConnection( private val mutableConnectionFailures = MutableStateFlow(null) override val connectionFailures = mutableConnectionFailures.filterNotNull() private var socket: Socket? = null - private var input: BufferedInputStream? = null - private var output: BufferedOutputStream? = null + private var input: java.io.InputStream? = null + private var output: java.io.OutputStream? = null private var compressionThreshold: Int? = null private var readerJob: Job? = null private var x = 0.0 @@ -70,7 +77,12 @@ class ModernOfflineMinecraftConnection( private var yaw = 0f private var pitch = 0f - override suspend fun connect(server: ServerProfile, username: String): ConnectedSession = withContext(Dispatchers.IO) { + override suspend fun connect(server: ServerProfile, username: String): ConnectedSession = connect(server, username, null) + + override suspend fun connect(server: ServerProfile, identity: MinecraftIdentity): ConnectedSession = + connect(server, identity.username, identity) + + private suspend fun connect(server: ServerProfile, username: String, identity: MinecraftIdentity?): ConnectedSession = withContext(Dispatchers.IO) { mutableConnectionFailures.value = null version = server.minecraftVersion connectionStage = "resolve" @@ -91,8 +103,8 @@ class ModernOfflineMinecraftConnection( connectionStage = "login" sendHandshake(endpoint) - sendLoginStart(username) - val session = awaitLoginSuccess() + sendLoginStart(username, identity?.uuid) + val session = awaitLoginSuccess(identity) sendPacket(packet { writeVarInt(LOGIN_ACKNOWLEDGED) }) connectionStage = "configuration" sendClientSettings(CONFIGURATION_CLIENT_SETTINGS) @@ -102,6 +114,12 @@ class ModernOfflineMinecraftConnection( connectedSocket.soTimeout = 0 readerJob = scope.launch { playLoop() } session + } catch (cancelled: kotlinx.coroutines.CancellationException) { + closeResources() + throw cancelled + } catch (failure: AuthenticationException) { + closeResources() + throw MinecraftConnectionException.Authentication("premium_${failure.problem.name.lowercase()}") } catch (failure: MinecraftConnectionException) { closeResources() throw failure @@ -192,13 +210,15 @@ class ModernOfflineMinecraftConnection( writeVarInt(LOGIN_STATE) }) - private fun sendLoginStart(username: String) = sendPacket(packet { + private fun sendLoginStart(username: String, profileId: String?) = sendPacket(packet { writeVarInt(LOGIN_START) writeProtocolString(username) - writeUuid(offlineUuid(username)) + writeUuid(profileId?.let { uuidFromProfile(it) } ?: offlineUuid(username)) }) - private fun awaitLoginSuccess(): ConnectedSession { + private suspend fun awaitLoginSuccess(identity: MinecraftIdentity?): ConnectedSession { + var encrypted = false + var authenticated = false while (open.get()) { val packetInput = readPacket(requireInput(), compressionThreshold) when (val packetId = packetInput.readVarInt().also { packetTrace.record("i", it) }) { @@ -215,6 +235,9 @@ class ModernOfflineMinecraftConnection( if (packetInput.readBoolean()) packetInput.readProtocolString() } if (version.protocol >= 776) packetInput.readUuid() // Login session ID + if (identity != null && (!authenticated || uuid != uuidFromProfile(identity.uuid) || username != identity.username)) { + throw MinecraftConnectionException.Authentication("premium_server_identity_mismatch") + } return ConnectedSession(version.protocol, username, uuid.toString()) } SET_COMPRESSION -> compressionThreshold = packetInput.readVarInt().also { @@ -222,7 +245,25 @@ class ModernOfflineMinecraftConnection( } LOGIN_PLUGIN_REQUEST -> rejectLoginPlugin(packetInput) 0x05 -> commonRequests.replyCookie(packetInput, 0x04) - 0x01 -> throw MinecraftConnectionException.Authentication("online_mode_requires_microsoft") + 0x01 -> { + if (encrypted) throw MinecraftConnectionException.Protocol("duplicate_encryption_request") + val account = identity ?: throw MinecraftConnectionException.Authentication("online_mode_requires_microsoft") + val encryption = OnlineLoginEncryption(packetInput) + if (!encryption.shouldAuthenticate) throw MinecraftConnectionException.Authentication("premium_requires_online_server") + joinSession(account, encryption.serverHash) + val secret = encryption.encryptedSecret() + val challenge = encryption.encryptedChallenge() + sendPacket(packet { + writeVarInt(0x01) + writeVarInt(secret.size); write(secret) + writeVarInt(challenge.size); write(challenge) + }) + // Wrap the existing buffered input: it may already contain encrypted bytes. + input = javax.crypto.CipherInputStream(requireInput(), encryption.streamCipher(javax.crypto.Cipher.DECRYPT_MODE)) + output = javax.crypto.CipherOutputStream(requireOutput(), encryption.streamCipher(javax.crypto.Cipher.ENCRYPT_MODE)) + encrypted = true + authenticated = true + } else -> throw MinecraftConnectionException.Protocol("unexpected_login_packet_$packetId") } } @@ -526,3 +567,9 @@ private fun String.minecraftText(): String = + +private fun uuidFromProfile(id: String): UUID { + val compact = id.replace("-", "") + require(Regex("[a-fA-F0-9]{32}").matches(compact)) + return UUID.fromString("${compact.substring(0, 8)}-${compact.substring(8, 12)}-${compact.substring(12, 16)}-${compact.substring(16, 20)}-${compact.substring(20)}") +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/OnlineLoginEncryption.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/OnlineLoginEncryption.kt new file mode 100644 index 0000000..d1e6d91 --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/protocol/modern/OnlineLoginEncryption.kt @@ -0,0 +1,40 @@ +package pl.syntaxdevteam.craftconnect.protocol.modern + +import java.io.DataInputStream +import java.math.BigInteger +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import pl.syntaxdevteam.craftconnect.protocol.legacy.readProtocolString +import pl.syntaxdevteam.craftconnect.protocol.legacy.readVarInt +import pl.syntaxdevteam.craftconnect.protocol.legacy.ProtocolCodecException + +internal class OnlineLoginEncryption(input: DataInputStream) { + private val serverId = input.readProtocolString(20) + private val publicKeyBytes = input.boundedBytes(4096) + private val challenge = input.boundedBytes(1024) + val shouldAuthenticate = input.readBoolean() + private val publicKey = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(publicKeyBytes)) + private val secret: SecretKey = KeyGenerator.getInstance("AES").apply { init(128) }.generateKey() + val serverHash: String = BigInteger(MessageDigest.getInstance("SHA-1").run { + update(serverId.toByteArray(Charsets.ISO_8859_1)); update(secret.encoded); digest(publicKeyBytes) + }).toString(16) + fun encryptedSecret(): ByteArray = encrypt(secret.encoded) + fun encryptedChallenge(): ByteArray = encrypt(challenge) + private fun encrypt(bytes: ByteArray) = Cipher.getInstance("RSA/ECB/PKCS1Padding").run { + init(Cipher.ENCRYPT_MODE, publicKey); doFinal(bytes) + } + fun streamCipher(mode: Int): Cipher = Cipher.getInstance("AES/CFB8/NoPadding").apply { + init(mode, secret, IvParameterSpec(secret.encoded)) + } +} + +private fun DataInputStream.boundedBytes(max: Int): ByteArray { + val length = readVarInt() + if (length !in 1..max) throw ProtocolCodecException("invalid_encryption_request") + return ByteArray(length).also { readFully(it) } +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/components/MicrosoftSignInCard.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/components/MicrosoftSignInCard.kt new file mode 100644 index 0000000..ca0e44f --- /dev/null +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/components/MicrosoftSignInCard.kt @@ -0,0 +1,60 @@ +package pl.syntaxdevteam.craftconnect.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import pl.syntaxdevteam.craftconnect.R +import pl.syntaxdevteam.craftconnect.domain.auth.* +import pl.syntaxdevteam.craftconnect.ui.theme.TextSecondary + +@Composable +fun MicrosoftSignInCard(state: MicrosoftSignInState, onStart: () -> Unit, onCancel: () -> Unit) { + val browser = LocalUriHandler.current + NeonCard { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.microsoft_accounts)) + Text(stringResource(R.string.microsoft_sign_in_description), color = TextSecondary) + when (state) { + MicrosoftSignInState.Idle -> GlowButton(stringResource(R.string.microsoft_sign_in), onClick = onStart) + MicrosoftSignInState.Starting -> Text(stringResource(R.string.microsoft_starting)) + is MicrosoftSignInState.Waiting -> { + Text(stringResource(R.string.microsoft_device_instructions)) + SelectionContainer { Text(state.userCode) } + GlowButton(stringResource(R.string.microsoft_open_browser)) { browser.openUri(state.verificationUri) } + } + is MicrosoftSignInState.Success -> { + Text(stringResource(R.string.microsoft_added, state.username)) + GlowButton(stringResource(R.string.microsoft_add_another), onClick = onStart) + } + is MicrosoftSignInState.Failed -> { + Text(stringResource(authProblemText(state.problem))) + GlowButton(stringResource(R.string.microsoft_retry), onClick = onStart) + } + } + if (state is MicrosoftSignInState.Waiting || state is MicrosoftSignInState.Starting) { + GlowButton(stringResource(R.string.cancel), onClick = onCancel) + } + } + } +} + +fun authProblemText(problem: AuthProblem): Int = when (problem) { + AuthProblem.NETWORK -> R.string.microsoft_error_network + AuthProblem.EXPIRED -> R.string.microsoft_error_expired + AuthProblem.DECLINED -> R.string.microsoft_error_declined + AuthProblem.CONFIGURATION -> R.string.microsoft_error_configuration + AuthProblem.APP_APPROVAL -> R.string.microsoft_error_approval + AuthProblem.NO_LICENSE -> R.string.microsoft_error_license + AuthProblem.NO_PROFILE -> R.string.microsoft_error_profile + AuthProblem.XBOX_ACCOUNT -> R.string.microsoft_error_xbox + AuthProblem.REAUTHENTICATE -> R.string.microsoft_error_reauthenticate + AuthProblem.STORAGE -> R.string.microsoft_error_storage + AuthProblem.INVALID_RESPONSE -> R.string.microsoft_error_response +} diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/AccountsScreen.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/AccountsScreen.kt index 04b8729..b032155 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/AccountsScreen.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/AccountsScreen.kt @@ -1,5 +1,10 @@ package pl.syntaxdevteam.craftconnect.ui.screens +import pl.syntaxdevteam.craftconnect.domain.auth.MicrosoftSignInState +import pl.syntaxdevteam.craftconnect.domain.model.AccountType.MICROSOFT +import pl.syntaxdevteam.craftconnect.domain.model.AccountType.OFFLINE +import pl.syntaxdevteam.craftconnect.ui.components.MicrosoftSignInCard + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,7 +20,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon @@ -49,6 +53,9 @@ fun AccountsScreen( onCreate: (String) -> Unit, onUpdate: (AccountProfile) -> Unit, onDelete: (String) -> Unit, + microsoftState: MicrosoftSignInState, + onMicrosoftSignIn: () -> Unit, + onCancelSignIn: () -> Unit, ) { var editorTarget by remember { mutableStateOf(null) } var editorVisible by remember { mutableStateOf(false) } @@ -72,7 +79,20 @@ fun AccountsScreen( modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - item { MicrosoftAccountPreview() } + item { MicrosoftSignInCard(microsoftState, onMicrosoftSignIn, onCancelSignIn) } + items(accounts.filter { it.type == MICROSOFT }, key = { it.id }) { account -> + NeonCard { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(account.username, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.microsoft_premium_account), color = TextSecondary) + } + IconButton(onClick = { deleteTarget = account }) { + Icon(Icons.Rounded.Delete, stringResource(R.string.delete_account), tint = TextSecondary) + } + } + } + } item { Text( stringResource(R.string.offline_accounts), @@ -81,10 +101,10 @@ fun AccountsScreen( modifier = Modifier.padding(top = 8.dp), ) } - if (accounts.isEmpty()) { + if (accounts.none { it.type == OFFLINE }) { item { Text(stringResource(R.string.no_offline_accounts), color = TextSecondary) } } - items(accounts, key = { it.id }) { account -> + items(accounts.filter { it.type == OFFLINE }, key = { it.id }) { account -> OfflineAccountRow( account, onEdit = { editorTarget = account; editorVisible = true }, @@ -97,7 +117,7 @@ fun AccountsScreen( if (editorVisible) { AccountEditorDialog( account = editorTarget, - unavailableUsernames = accounts.filterNot { it.id == editorTarget?.id }.map { it.username }, + unavailableUsernames = accounts.filter { it.type == OFFLINE && it.id != editorTarget?.id }.map { it.username }, onDismiss = { editorVisible = false }, ) { username -> editorTarget?.let { onUpdate(it.copy(username = username)) } ?: onCreate(username) @@ -116,20 +136,6 @@ fun AccountsScreen( } } -@Composable -private fun MicrosoftAccountPreview() { - NeonCard { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Rounded.Lock, null, tint = Crimson, modifier = Modifier.size(34.dp)) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text(stringResource(R.string.microsoft_accounts), fontWeight = FontWeight.Bold) - Text(stringResource(R.string.microsoft_accounts_coming_soon), color = TextSecondary, fontSize = 12.sp) - } - } - } -} - @Composable private fun OfflineAccountRow(account: AccountProfile, onEdit: () -> Unit, onDelete: () -> Unit) { NeonCard { diff --git a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ServersScreen.kt b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ServersScreen.kt index 56efaa6..437fe54 100644 --- a/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ServersScreen.kt +++ b/app/src/main/java/pl/syntaxdevteam/craftconnect/ui/screens/ServersScreen.kt @@ -1,5 +1,7 @@ package pl.syntaxdevteam.craftconnect.ui.screens +import pl.syntaxdevteam.craftconnect.domain.model.AccountType.MICROSOFT + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -67,7 +69,7 @@ fun ServersScreen( onCreate: (name: String, address: String, favorite: Boolean, version: MinecraftVersion) -> Unit, onUpdate: (ServerProfile) -> Unit, onDelete: (String) -> Unit, - onConnect: (ServerProfile, String) -> Unit, + onConnect: (ServerProfile, AccountProfile) -> Unit, ) { var query by remember { mutableStateOf("") } var favoritesOnly by remember { mutableStateOf(false) } @@ -292,11 +294,12 @@ private fun OfflineConnectDialog( connectionError: String?, accounts: List, onDismiss: () -> Unit, - onConnect: (String, String) -> Unit, + onConnect: (String, AccountProfile) -> Unit, ) { var address by remember(initialAddress) { mutableStateOf(initialAddress) } - var username by remember(accounts) { mutableStateOf(accounts.firstOrNull()?.username.orEmpty()) } - val valid = address.isNotBlank() && Regex("[A-Za-z0-9_]{3,16}").matches(username) + var selectedId by remember(accounts) { mutableStateOf(accounts.firstOrNull()?.id) } + val selected = accounts.find { it.id == selectedId } + val valid = address.isNotBlank() && selected != null AlertDialog( onDismissRequest = { if (!connecting) onDismiss() }, @@ -311,9 +314,9 @@ private fun OfflineConnectDialog( enabled = !connecting, ) OutlinedTextField( - value = username, - onValueChange = { username = it }, - label = { Text(stringResource(R.string.offline_username)) }, + value = selected?.username.orEmpty(), + onValueChange = {}, + label = { Text(stringResource(R.string.choose_saved_account)) }, singleLine = true, enabled = !connecting, readOnly = true, @@ -324,7 +327,7 @@ private fun OfflineConnectDialog( Text(stringResource(R.string.choose_saved_account), color = TextSecondary, fontSize = 12.sp) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(accounts, key = { it.id }) { account -> - GlowButton(account.username, onClick = { username = account.username }) + GlowButton(stringResource(R.string.account_choice_label, account.username, stringResource(if (account.type == MICROSOFT) R.string.microsoft_premium_account else R.string.offline_account)), onClick = { if (!connecting) selectedId = account.id }) } } } @@ -341,7 +344,7 @@ private fun OfflineConnectDialog( confirmButton = { GlowButton( if (connecting) stringResource(R.string.connecting) else stringResource(R.string.connect), - onClick = { if (valid && !connecting) onConnect(address.trim(), username) }, + onClick = { if (valid && !connecting) onConnect(address.trim(), checkNotNull(selected)) }, ) }, dismissButton = { GlowButton(stringResource(R.string.cancel), onClick = onDismiss) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32d0425..c3db8ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,7 +13,6 @@ Non-premium accounts Non-premium · offline mode Premium accounts (Microsoft) - Microsoft authentication will be available in a future release. Add non-premium account Edit account Delete account @@ -21,7 +20,7 @@ No non-premium accounts yet. Use + to add one. Use 3–16 letters, digits or underscores. An account with this username already exists. - Add a non-premium account in Account manager before connecting. + Add an account in Account manager before connecting. Saved account: Search servers… Search players… @@ -92,7 +91,7 @@ Waiting for server messages… Minecraft Java version %1$s (protocol %2$d) - Uses Minecraft Java %1$s protocol (%2$d). The server must allow offline-mode logins. + Uses Minecraft Java %1$s protocol (%2$d). Choose a Microsoft account for online-mode servers. Waiting for the server player list… Connect to a server to view its players. No players match your search. @@ -102,6 +101,27 @@ Connecting to the Minecraft server… Active connection: %1$s Disconnect + Sign in with Microsoft + Add an account with Minecraft: Java Edition to connect to online-mode servers. Enter your password only on the Microsoft website. + Preparing Microsoft sign-in… + Open Microsoft sign-in, enter this code, then return here. Only use the code for your own account. + Open Microsoft sign-in + Account %1$s added. + Add another Microsoft account + Try sign-in again + Microsoft · Premium + %1$s · %2$s + Could not reach the sign-in service. Check your connection and try again. + The sign-in code expired. Start sign-in again. + Sign-in was declined. You can try again when ready. + Microsoft sign-in is not configured for this app yet. The publisher must enable personal Microsoft accounts and public client sign-in. + Minecraft services refused this app. CraftConnect may still be awaiting Mojang approval. + This account has no active Minecraft: Java Edition entitlement. + No Java Edition player profile was found. Complete your profile on minecraft.net. + Xbox sign-in was refused. Check your Xbox profile, family permissions, and account restrictions. + Your saved session is no longer valid. Sign in with Microsoft again in Accounts. + The account could not be saved securely on this device. Try again. + Sign-in could not be completed. Please try again. diff --git a/app/src/test/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftAccountManagerTest.kt b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftAccountManagerTest.kt new file mode 100644 index 0000000..a6fac30 --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftAccountManagerTest.kt @@ -0,0 +1,95 @@ +package pl.syntaxdevteam.craftconnect.data.auth + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.* +import org.junit.Assert.* +import org.junit.Test +import pl.syntaxdevteam.craftconnect.data.account.* +import pl.syntaxdevteam.craftconnect.domain.auth.* +import pl.syntaxdevteam.craftconnect.domain.model.* + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class MicrosoftAccountManagerTest { + private val uuid = "12345678123456781234567812345678" + private class Storage : AccountStorage { + var value: String? = null + override fun read() = value + override fun write(value: String) { this.value = value } + } + private class Tokens : RefreshTokenStore { + val values = mutableMapOf() + override fun read(id: String) = values[id] + override fun write(id: String, token: String) { values[id] = token } + override fun delete(id: String) { values.remove(id) } + } + private inner class Api : MicrosoftAuthApi { + var problem: AuthProblem? = null + var refreshes = 0 + var name = "Player" + var wait: CompletableDeferred? = null + override suspend fun begin() = DeviceAuthorization("secret", "user-code", "https://microsoft.com/devicelogin", 100000, 1) + override suspend fun awaitTokens(authorization: DeviceAuthorization): MicrosoftTokens { + wait?.await() + return MicrosoftTokens("ms-secret", "refresh-secret") + } + override suspend fun refresh(refreshToken: String): MicrosoftTokens { refreshes++; return MicrosoftTokens("ms-new", "rotated-secret") } + override suspend fun minecraft(accessToken: String): VerifiedMinecraftAccount { + problem?.let { throw AuthenticationException(it) } + return VerifiedMinecraftAccount(MinecraftIdentity(name, uuid, "mc-secret"), 3_600_000) + } + override suspend fun join(identity: MinecraftIdentity, serverHash: String) = Unit + } + + @Test fun `verified account persists identity only and deduplicates by UUID`() = runTest { + val storage = Storage(); val accounts = SharedPreferencesAccountRepository(storage); val api = Api(); val tokens = Tokens() + val manager = MicrosoftAccountManager(accounts, api, tokens, backgroundScope, { 0 }) + manager.start(); runCurrent() + assertEquals(MicrosoftSignInState.Success("Player"), manager.state.value) + assertEquals(AccountType.MICROSOFT, accounts.accounts.value.single().type) + assertEquals("refresh-secret", tokens.values.values.single()) + assertFalse(String(java.util.Base64.getDecoder().decode(storage.value)).contains("secret")) + api.name = "Renamed" + manager.start(); runCurrent() + assertEquals("Renamed", accounts.accounts.value.single().username) + } + + @Test fun `recreated manager refreshes before connecting and deletion removes credentials`() = runTest { + val accounts = SharedPreferencesAccountRepository(Storage()); val api = Api(); val tokens = Tokens() + val profile = accounts.saveMicrosoft("Player", uuid) + tokens.write(profile.id, "old") + val manager = MicrosoftAccountManager(accounts, api, tokens, backgroundScope, { 0 }) + assertEquals(uuid, manager.identity(profile).uuid) + manager.identity(profile) + assertEquals(1, api.refreshes) + assertEquals("rotated-secret", tokens.read(profile.id)) + manager.delete(profile.id) + assertTrue(accounts.accounts.value.isEmpty()); assertTrue(tokens.values.isEmpty()) + try { manager.identity(profile); fail() } + catch (failure: AuthenticationException) { assertEquals(AuthProblem.REAUTHENTICATE, failure.problem) } + } + + @Test fun `approval failure never publishes an authenticated profile`() = runTest { + val accounts = SharedPreferencesAccountRepository(Storage()); val api = Api().apply { problem = AuthProblem.APP_APPROVAL }; val tokens = Tokens() + val manager = MicrosoftAccountManager(accounts, api, tokens, backgroundScope) + manager.start(); runCurrent() + assertEquals(MicrosoftSignInState.Failed(AuthProblem.APP_APPROVAL), manager.state.value) + assertTrue(accounts.accounts.value.isEmpty()); assertTrue(tokens.values.isEmpty()) + } + + @Test fun `cancelled sign in cannot save an account later`() = runTest { + val accounts = SharedPreferencesAccountRepository(Storage()); val gate = CompletableDeferred(); val api = Api().apply { wait = gate }; val tokens = Tokens() + val manager = MicrosoftAccountManager(accounts, api, tokens, backgroundScope) + manager.start(); runCurrent(); manager.cancel(); gate.complete(Unit); runCurrent() + assertEquals(MicrosoftSignInState.Idle, manager.state.value) + assertTrue(accounts.accounts.value.isEmpty()); assertTrue(tokens.values.isEmpty()) + } + + @Test fun `offline identity cannot overwrite a verified identity`() { + val accounts = SharedPreferencesAccountRepository(Storage()) + val premium = accounts.saveMicrosoft("Player", uuid) + accounts.createOffline("Player") + try { accounts.update(premium.copy(type = AccountType.OFFLINE, username = "Spoofed")); fail() } + catch (_: IllegalArgumentException) { } + assertEquals("Player", accounts.accounts.value.first { it.id == premium.id }.username) + } +} diff --git a/app/src/test/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftMinecraftAuthApiTest.kt b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftMinecraftAuthApiTest.kt new file mode 100644 index 0000000..f49bec6 --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/auth/MicrosoftMinecraftAuthApiTest.kt @@ -0,0 +1,103 @@ +package pl.syntaxdevteam.craftconnect.data.auth + +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import pl.syntaxdevteam.craftconnect.domain.auth.* + +class MicrosoftMinecraftAuthApiTest { + private val uuid = "12345678123456781234567812345678" + private fun response(status: Int, json: String) = AuthResponse(status, JSONObject(json)) + + @Test fun `device flow observes polling and slow down then returns tokens`() = runTest { + var now = 0L + val waits = mutableListOf() + val responses = ArrayDeque(listOf(response(400, """{"error":"authorization_pending"}"""), + response(400, """{"error":"slow_down"}"""), response(200, """{"access_token":"access","refresh_token":"refresh"}"""))) + val api = MicrosoftMinecraftAuthApi("client", AuthTransport { url, body, _, _ -> + assertTrue(url.endsWith("/consumers/oauth2/v2.0/token")) + assertTrue(body!!.contains("device_code=device")) + responses.removeFirst() + }, { now }, { now += it; waits += it }) + val tokens = api.awaitTokens(DeviceAuthorization("device", "code", "https://microsoft.com/devicelogin", 60_000, 5000)) + assertEquals(listOf(5000L, 5000L, 10000L), waits) + assertEquals("refresh", tokens.refreshToken) + } + + @Test fun `expired code sends no late token request`() = runTest { + var now = 0L + val api = MicrosoftMinecraftAuthApi("client", AuthTransport { _, _, _, _ -> error("No request expected") }, { now }, { now += it }) + try { api.awaitTokens(DeviceAuthorization("d", "u", "", 1000, 5000)); fail() } + catch (failure: AuthenticationException) { assertEquals(AuthProblem.EXPIRED, failure.problem) } + } + + @Test fun `declined authorization stops polling`() = runTest { + val api = MicrosoftMinecraftAuthApi("client", AuthTransport { _, _, _, _ -> response(400, """{"error":"authorization_declined"}""") }, { 0 }, {}) + try { api.awaitTokens(DeviceAuthorization("d", "u", "", 10000, 1)); fail() } + catch (failure: AuthenticationException) { assertEquals(AuthProblem.DECLINED, failure.problem) } + } + + @Test fun `unexpected verification host is rejected`() = runTest { + val api = MicrosoftMinecraftAuthApi("client", AuthTransport { _, _, _, _ -> response(200, """{"verification_uri":"https://example.com/phishing"}""") }) + try { api.begin(); fail() } + catch (failure: AuthenticationException) { assertEquals(AuthProblem.INVALID_RESPONSE, failure.problem) } + } + + @Test fun `Minecraft pipeline verifies entitlement and returns service profile`() = runTest { + val requests = mutableListOf() + val api = MicrosoftMinecraftAuthApi("client", AuthTransport { url, body, _, bearer -> + requests += url + when { + url.contains("user/authenticate") -> { + assertEquals("d=ms-token", JSONObject(body!!).getJSONObject("Properties").getString("RpsTicket")) + response(200, """{"Token":"user","DisplayClaims":{"xui":[{"uhs":"hash"}]}}""") + } + url.contains("xsts/authorize") -> response(200, """{"Token":"xsts","DisplayClaims":{"xui":[{"uhs":"hash"}]}}""") + url.endsWith("login_with_xbox") -> { + assertEquals("XBL3.0 x=hash;xsts", JSONObject(body!!).getString("identityToken")) + response(200, """{"access_token":"mc-token","expires_in":3600}""") + } + url.endsWith("mcstore") -> { assertEquals("mc-token", bearer); response(200, """{"items":[{"name":"game_minecraft"}]}""") } + else -> { assertEquals("mc-token", bearer); response(200, """{"id":"$uuid","name":"Player"}""") } + } + }, { 100 }) + val result = api.minecraft("ms-token") + assertEquals(uuid, result.identity.uuid) + assertEquals("Player", result.identity.username) + assertEquals(3_600_100L, result.expiresAt) + assertEquals(5, requests.size) + } + + @Test fun `no license and unapproved app are distinct failures`() = runTest { + for (blocked in listOf(false, true)) { + val api = MicrosoftMinecraftAuthApi("client", AuthTransport { url, _, _, _ -> when { + url.contains("xboxlive.com") -> response(200, """{"Token":"x","DisplayClaims":{"xui":[{"uhs":"h"}]}}""") + url.endsWith("login_with_xbox") -> if (blocked) response(403, "{}") else response(200, """{"access_token":"mc","expires_in":10}""") + url.endsWith("mcstore") -> response(200, """{"items":[]}""") + else -> error("Profile must not be fetched without entitlement") + } }) + try { api.minecraft("ms"); fail() } + catch (failure: AuthenticationException) { assertEquals(if (blocked) AuthProblem.APP_APPROVAL else AuthProblem.NO_LICENSE, failure.problem) } + } + } + + @Test fun `refresh retains previous token if provider does not rotate it`() = runTest { + val api = MicrosoftMinecraftAuthApi("client", AuthTransport { _, body, _, _ -> + assertTrue(body!!.contains("grant_type=refresh_token")) + response(200, """{"access_token":"new"}""") + }) + assertEquals("old", api.refresh("old").refreshToken) + } + + @Test fun `join posts only to session server and requires 204`() = runTest { + val api = MicrosoftMinecraftAuthApi("client", AuthTransport { url, body, _, _ -> + assertEquals("https://sessionserver.mojang.com/session/minecraft/join", url) + assertEquals("mc", JSONObject(body!!).getString("accessToken")) + assertEquals(uuid, JSONObject(body).getString("selectedProfile")) + assertEquals("-123", JSONObject(body).getString("serverId")) + response(204, "{}") + }) + api.join(MinecraftIdentity("Player", uuid, "mc"), "-123") + } +} diff --git a/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt index 60f453e..0d003df 100644 --- a/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/data/session/DefaultSessionManagerTest.kt @@ -20,6 +20,31 @@ import pl.syntaxdevteam.craftconnect.protocol.MinecraftConnection import pl.syntaxdevteam.craftconnect.protocol.MinecraftConnectionException class DefaultSessionManagerTest { + @Test + fun premiumAuthenticationFailureDoesNotFallBackToOffline() = runTest { + val connection = FakeConnection() + val manager = DefaultSessionManager(connection, observerScope = backgroundScope, premiumIdentity = { + throw pl.syntaxdevteam.craftconnect.domain.auth.AuthenticationException(pl.syntaxdevteam.craftconnect.domain.auth.AuthProblem.REAUTHENTICATE) + }) + manager.connect(server, pl.syntaxdevteam.craftconnect.domain.model.AccountProfile("microsoft:id", "Player", pl.syntaxdevteam.craftconnect.domain.model.AccountType.MICROSOFT)) + assertEquals(ConnectionState.FAILED, manager.session.value.connectionState) + assertEquals("premium_reauthenticate", manager.session.value.lastError?.diagnosticCode) + assertEquals(0, connection.offlineConnections) + } + + @Test + fun premiumConnectPassesVerifiedIdentityInsteadOfStoredDisplayName() = runTest { + val connection = FakeConnection() + val identity = pl.syntaxdevteam.craftconnect.domain.auth.MinecraftIdentity("NewName", "profile-id", "secret") + val manager = DefaultSessionManager(connection, observerScope = backgroundScope, premiumIdentity = { identity }) + manager.connect(server, pl.syntaxdevteam.craftconnect.domain.model.AccountProfile("microsoft:id", "OldName", pl.syntaxdevteam.craftconnect.domain.model.AccountType.MICROSOFT)) + assertEquals(ConnectionState.CONNECTED, manager.session.value.connectionState) + assertEquals("NewName", manager.session.value.username) + assertEquals("profile-id", manager.session.value.uuid) + assertEquals(0, connection.offlineConnections) + assertEquals(identity, connection.premiumIdentity) + } + @Test fun successfulConnectionPublishesConnectedSession() = runTest { val manager = DefaultSessionManager(FakeConnection(), clock = { 42L }) @@ -149,11 +174,18 @@ class DefaultSessionManagerTest { ) : MinecraftConnection { val failure = MutableStateFlow(null) override val connectionFailures = failure.filterNotNull() + var offlineConnections = 0 + var premiumIdentity: pl.syntaxdevteam.craftconnect.domain.auth.MinecraftIdentity? = null + override suspend fun connect(server: ServerProfile, identity: pl.syntaxdevteam.craftconnect.domain.auth.MinecraftIdentity): ConnectedSession { + premiumIdentity = identity + return ConnectedSession(775, identity.username, identity.uuid) + } var disconnected = false var sentCommands = 0 var submittedAction: String? = null var submittedValues: Map? = null override suspend fun connect(server: ServerProfile, username: String): ConnectedSession { + offlineConnections++ connectFailure?.let { throw it } failure.value = receiveFailure return ConnectedSession( diff --git a/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernPremiumConnectionTest.kt b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernPremiumConnectionTest.kt new file mode 100644 index 0000000..134c9d3 --- /dev/null +++ b/app/src/test/java/pl/syntaxdevteam/craftconnect/protocol/modern/ModernPremiumConnectionTest.kt @@ -0,0 +1,115 @@ +package pl.syntaxdevteam.craftconnect.protocol.modern + +import java.net.ServerSocket +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.math.BigInteger +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.CipherOutputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Test +import pl.syntaxdevteam.craftconnect.domain.auth.MinecraftIdentity +import pl.syntaxdevteam.craftconnect.domain.model.* +import pl.syntaxdevteam.craftconnect.protocol.MinecraftConnectionException +import pl.syntaxdevteam.craftconnect.protocol.legacy.* + +class ModernPremiumConnectionTest { + private val uuid = UUID.fromString("12345678-1234-5678-1234-567812345678") + private val identity = MinecraftIdentity("PremiumPlayer", uuid.toString().replace("-", ""), "never-send-to-server") + + @Test fun `encrypted login and compressed keepalive work on all supported versions`() = runBlocking { + for (version in MinecraftVersion.entries) verify(version) + } + + private suspend fun verify(version: MinecraftVersion) = coroutineScope { + val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) }.generateKeyPair() + val challenge = byteArrayOf(10, 20, 30, 40) + var joinedHash: String? = null + val ready = CompletableDeferred() + val done = CompletableDeferred() + ServerSocket(0).use { listener -> + val peer = async(Dispatchers.IO) { + listener.accept().use { socket -> + socket.soTimeout = 5000 + val rawInput = socket.getInputStream(); val rawOutput = socket.getOutputStream() + readPacket(rawInput, null) + val start = readPacket(rawInput, null) + assertEquals(0, start.readVarInt()); assertEquals(identity.username, start.readProtocolString()) + assertEquals(uuid, UUID(start.readLong(), start.readLong())) + rawOutput.write(frame(packet { + writeVarInt(1); writeProtocolString("") + writeVarInt(pair.public.encoded.size); write(pair.public.encoded) + writeVarInt(challenge.size); write(challenge); writeBoolean(true) + }, null)); rawOutput.flush() + val response = readPacket(rawInput, null) + assertEquals(1, response.readVarInt()) + fun decrypt(): ByteArray { + val bytes = ByteArray(response.readVarInt()).also { response.readFully(it) } + return Cipher.getInstance("RSA/ECB/PKCS1Padding").run { init(Cipher.DECRYPT_MODE, pair.private); doFinal(bytes) } + } + val secret = decrypt() + assertArrayEquals(challenge, decrypt()) + assertEquals(BigInteger(MessageDigest.getInstance("SHA-1").run { + update(secret); digest(pair.public.encoded) + }).toString(16), joinedHash) + fun cipher(mode: Int) = Cipher.getInstance("AES/CFB8/NoPadding").apply { + init(mode, SecretKeySpec(secret, "AES"), IvParameterSpec(secret)) + } + val input = CipherInputStream(rawInput, cipher(Cipher.DECRYPT_MODE)) + val output = CipherOutputStream(rawOutput, cipher(Cipher.ENCRYPT_MODE)) + output.write(frame(packet { writeVarInt(3); writeVarInt(1) }, null)) + output.write(frame(packet { + writeVarInt(2); writeLong(uuid.mostSignificantBits); writeLong(uuid.leastSignificantBits) + writeProtocolString(identity.username); writeVarInt(0) + if (version.protocol >= 776) { writeLong(0); writeLong(0) } + }, 1)); output.flush() + assertEquals(3, readPacket(input, 1).readVarInt()) + assertEquals(0, readPacket(input, 1).readVarInt()) + assertEquals(2, readPacket(input, 1).readVarInt()) + output.write(frame(packet { writeVarInt(3) }, 1)); output.flush() + assertEquals(3, readPacket(input, 1).readVarInt()) + output.write(frame(packet { writeVarInt(if (version == MinecraftVersion.JAVA_26_3) 0x2D else 0x2C); writeLong(54321) }, 1)); output.flush() + val pong = readPacket(input, 1) + assertEquals(0x1C, pong.readVarInt()); assertEquals(54321L, pong.readLong()) + ready.complete(Unit) + done.await() + } + } + val connection = ModernOfflineMinecraftConnection(joinSession = { account, hash -> + assertSame(identity, account); joinedHash = hash + }) + try { + val session = connection.connect(server(listener.localPort, version), identity) + assertEquals(uuid.toString(), session.uuid) + withTimeout(5000) { ready.await() } + } finally { done.complete(Unit); connection.disconnect() } + peer.await() + } + } + + @Test fun `premium profile cannot silently fall back to offline login`() = runBlocking { + ServerSocket(0).use { listener -> + val peer = async(Dispatchers.IO) { + listener.accept().use { socket -> + socket.soTimeout = 5000 + readPacket(socket.getInputStream(), null); readPacket(socket.getInputStream(), null) + socket.getOutputStream().write(frame(packet { + writeVarInt(2); writeLong(uuid.mostSignificantBits); writeLong(uuid.leastSignificantBits) + writeProtocolString(identity.username); writeVarInt(0) + }, null)) + socket.getOutputStream().flush() + } + } + try { ModernOfflineMinecraftConnection().connect(server(listener.localPort, MinecraftVersion.JAVA_26_1), identity); fail() } + catch (failure: MinecraftConnectionException.Authentication) { assertEquals("premium_server_identity_mismatch", failure.diagnosticCode) } + peer.await() + } + } + + private fun server(port: Int, version: MinecraftVersion) = ServerProfile("test", "test", "127.0.0.1:$port", true, 0, 20, 0, minecraftVersion = version) +} diff --git a/docs/MICROSOFT_ACCOUNTS.md b/docs/MICROSOFT_ACCOUNTS.md new file mode 100644 index 0000000..fe072bd --- /dev/null +++ b/docs/MICROSOFT_ACCOUNTS.md @@ -0,0 +1,78 @@ +# Microsoft / premium accounts + +CraftConnect now supports adding personal Microsoft accounts through OAuth device +code authorization. The app shows a short code and opens the Microsoft verification +page in the system browser. Passwords are never collected by CraftConnect. The +process-scoped sign-in survives Activity recreation; Cancel stops local polling. + +The public client ID is `931c77bd-a884-457d-9dbd-da85c2fca433`. This is an application +identifier, not a secret. Do not add a client secret to an Android application. + +## Publisher prerequisites + +Before testing with a real account: + +1. Configure this registration in Microsoft Entra to allow **personal Microsoft + accounts** (the previously observed single-organization setting does not). +2. Enable **Allow public client flows** for device code authorization. This flow + does not need a redirect URI. Requested scopes: `XboxLive.signin offline_access`. +3. Wait for Mojang to approve this exact client ID through AppID Review. + +This code change does not modify the Entra registration or grant consent on a +user's behalf. The UI distinguishes configuration failures, refused Minecraft app +access, missing Java entitlement/profile, Xbox restrictions, expired codes, and +sessions requiring sign-in again. A refusal from Minecraft is not proof that the +review is still pending; the message deliberately says it *may* be pending. + +## Account and session lifecycle + +Microsoft tokens are exchanged for Xbox Live, XSTS, and Minecraft access tokens. +The account is added only after checking Java Edition entitlements and retrieving +its player profile. Its name and UUID come from Minecraft; they cannot be edited. +Profiles are deduplicated by UUID, so reauthorization updates a renamed player. +Offline and Microsoft profiles may have the same name and remain distinct. + +Only the refresh token is persisted, using AES-GCM and a device-bound Android +Keystore key. Encrypted files are in `noBackupFilesDir`, with the account ID bound +as additional authenticated data. Access tokens stay in memory. The existing +profile list contains display names, IDs and types only, preserving its v1 format. +Restoring that list to another device requires Microsoft sign-in again. + +Before connecting, expired/missing in-memory sessions are refreshed. A rotated +refresh token is saved before contacting the downstream Xbox/Minecraft services. +Removing an account removes its token and cached session; deleting the currently +connected premium account also disconnects that session. This is a local sign-out, +not revocation of Microsoft's application consent. + +Premium connections use the verified UUID in Login Start, Mojang session join, +RSA challenge response, and AES/CFB8 encryption. The session token is sent only to +Mojang over HTTPS, never to the Minecraft server. Premium profiles reject a server +that skips authenticated login or returns a mismatching identity; there is no +silent fallback to an offline identity. Existing offline profiles remain available +for explicitly offline-mode servers. + +## Validation and limits + +Unit tests cover the authorization polling interval/slow-down, cancellation and +expiry, denied sign-in, app approval and entitlement errors, refresh rotation, +UUID identity persistence and deletion. A local TCP test server exercises RSA, +Mojang join hash, encrypted/compressed login and keep-alive on protocols 775–777. +It does not contact Microsoft or Mojang or require real credentials. + +Live Microsoft consent, Mojang approval and a real online-mode server have not +been tested here. Android Keystore and browser return behavior also need an actual +device/emulator check. Signed chat/profile certificates are not implemented by +this change: servers that require a secure chat profile may reject the connection +or chat. Do not disable a server's security settings to work around this limitation. + +Validation: + +```sh +./gradlew --no-daemon :app:assembleDebug :app:lintDebug :app:testDebugUnitTest +``` + +Protocol/authentication references: + +- https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code +- https://github.com/PrismLauncher/PrismLauncher/tree/develop/launcher/minecraft/auth/steps +- https://github.com/GeyserMC/MCProtocolLib/blob/master/protocol/src/main/java/org/geysermc/mcprotocollib/protocol/packet/login/clientbound/ClientboundHelloPacket.java