Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
29 changes: 24 additions & 5 deletions app/src/main/java/pl/syntaxdevteam/craftconnect/CraftConnectApp.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -48,6 +52,7 @@ fun CraftConnectApp(
accountRepository: AccountRepository,
sessionScope: CoroutineScope,
onBeforeConnect: () -> Unit,
microsoftAccounts: MicrosoftAccountManager,
) {
val sessionManager = remember { sessionManagerFactory.create() }
var destination by rememberSaveable {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,29 @@ 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 {
publish(mutableAccounts.value + it)
}
}

@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 })
Expand Down
Original file line number Diff line number Diff line change
@@ -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, String>): String = fields.joinToString("&") {
URLEncoder.encode(it.first, "UTF-8") + "=" + URLEncoder.encode(it.second, "UTF-8")
}
Original file line number Diff line number Diff line change
@@ -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" }
}
Loading
Loading