Skip to content

Commit f83f447

Browse files
committed
feat(auth): implement v0.3 premium identity and Microsoft PKCE OAuth
1 parent 29f51fa commit f83f447

6 files changed

Lines changed: 239 additions & 3 deletions

File tree

‎docs/ROADMAP.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ Goal: add official premium authentication without changing the fundamental Minec
264264

265265
- [ ] Introduce account manager/service.
266266
- [ ] Preserve offline account support.
267-
- [ ] Add Microsoft account implementation/session model.
267+
- [x] Add a verified premium account type and token-safe session class; tested.
268268
- [ ] Support multiple saved accounts.
269269
- [ ] Allow account selection per launch or as launcher default.
270270
- [ ] Display account type clearly.
@@ -280,8 +280,8 @@ Goal: add official premium authentication without changing the fundamental Minec
280280

281281
## Microsoft authentication
282282

283-
- [ ] Implement browser-based Microsoft OAuth flow suitable for desktop apps.
284-
- [ ] Use PKCE where applicable.
283+
- [ ] 🚧 IN PROGRESS — native public-client OAuth request/code exchange and refresh implemented; localhost browser callback UI remains.
284+
- [x] Implement S256 PKCE, cryptographic CSRF state and unique request verifier; tested.
285285
- [ ] Never request/store Microsoft password.
286286
- [ ] Exchange Microsoft identity for Xbox Live authentication.
287287
- [ ] Complete XSTS authentication.

‎docs/WORKLOG.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,40 @@ Every code/build/documentation/CI task must add an entry according to `AGENTS.md
88

99
---
1010

11+
## 2026-10-19 — First v0.3 Microsoft OAuth milestone
12+
13+
**Milestone:** v0.3
14+
**Author/Agent:** ChatGPT GPT-6
15+
**Status:** Partial — first authentication phase
16+
17+
### Completed
18+
- Added validated premium Java account and redacted session type.
19+
- Added native public-client browser PKCE S256, CSRF nonce and Microsoft authorization code/refresh exchange.
20+
- Refused fake offline credentials for premium accounts.
21+
- Added deterministic MockEngine tests for PKCE and OAuth tokens.
22+
23+
### Changed files
24+
- launcher-core/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/core/account/PremiumAccount.kt
25+
- launcher-core/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/core/account/MicrosoftOAuth.kt
26+
- launcher-core/src/test/kotlin/pl/syntaxdevteam/syntaxlauncher/core/account/MicrosoftOAuthTest.kt
27+
- launcher-core/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/core/launch/LaunchModels.kt
28+
- docs/ROADMAP.md and docs/WORKLOG.md
29+
30+
### Verification
31+
- ./gradlew test — NOT RUN locally, pending GitHub Actions.
32+
- ./gradlew build — NOT RUN locally, pending GitHub Actions.
33+
- Live account sign-in — NOT RUN; requires application registration.
34+
35+
### Roadmap updates
36+
- [x] v0.3 premium identity and PKCE request/token exchange.
37+
- [ ] v0.3 Xbox, XSTS, Minecraft Services, account GUI and vault pending.
38+
39+
### Follow-ups / known issues
40+
- Use an independently registered app client ID, never a client secret.
41+
- Refresh tokens remain in memory until a secure OS vault is implemented.
42+
43+
---
44+
1145
## 2026-10-19 — Waive v0.2 manual gate once and begin v0.3
1246

1347
**Milestone:** v0.2 transition / v0.3 Premium authentication
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.account
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.plugins.ResponseException
5+
import io.ktor.client.request.post
6+
import io.ktor.client.request.setBody
7+
import io.ktor.client.statement.bodyAsText
8+
import io.ktor.http.ContentType
9+
import io.ktor.http.contentType
10+
import java.net.URLEncoder
11+
import java.nio.charset.StandardCharsets
12+
import java.security.MessageDigest
13+
import java.security.SecureRandom
14+
import java.util.Base64
15+
import java.util.UUID
16+
import kotlinx.coroutines.CancellationException
17+
import kotlinx.serialization.SerialName
18+
import kotlinx.serialization.Serializable
19+
import kotlinx.serialization.json.Json
20+
21+
/** Fresh state and PKCE verifier for a single native-browser authorization request. */
22+
class MicrosoftOAuthRequest(
23+
val url: String, val redirectUri: String, val state: String, val codeVerifier: String,
24+
) {
25+
override fun toString() = "MicrosoftOAuthRequest([REDACTED])"
26+
}
27+
28+
/** Tokens must never be printed or persisted without an OS-backed secrets vault. */
29+
class MicrosoftOAuthTokens(val accessToken: String, val refreshToken: String?) {
30+
override fun toString() = "MicrosoftOAuthTokens([REDACTED])"
31+
}
32+
33+
@Serializable private data class OAuthResponse(
34+
@SerialName("access_token") val access: String,
35+
@SerialName("refresh_token") val refresh: String? = null,
36+
)
37+
38+
class MicrosoftOAuth(private val client: HttpClient, val applicationId: String) {
39+
private val random = SecureRandom()
40+
private val json = Json { ignoreUnknownKeys = true }
41+
init { require(runCatching { UUID.fromString(applicationId) }.isSuccess) {
42+
"Provide your own Microsoft native public-client application ID."
43+
} }
44+
45+
fun begin(redirectUri: String): MicrosoftOAuthRequest {
46+
require(Regex("http://localhost:[0-9]{1,5}/callback").matches(redirectUri)) {
47+
"Only an ephemeral localhost callback is accepted."
48+
}
49+
val verifier = nonce()
50+
val state = nonce()
51+
val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(
52+
MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(StandardCharsets.US_ASCII)))
53+
val data = linkedMapOf(
54+
"client_id" to applicationId, "response_type" to "code",
55+
"redirect_uri" to redirectUri, "response_mode" to "query",
56+
"scope" to "XboxLive.signin offline_access",
57+
"code_challenge_method" to "S256", "code_challenge" to challenge, "state" to state,
58+
)
59+
val query = data.entries.joinToString("&") { enc(it.key) + "=" + enc(it.value) }
60+
return MicrosoftOAuthRequest(
61+
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?" + query,
62+
redirectUri, state, verifier,
63+
)
64+
}
65+
66+
suspend fun exchange(code: String, request: MicrosoftOAuthRequest): MicrosoftOAuthTokens {
67+
require(code.isNotBlank())
68+
return token(mapOf("grant_type" to "authorization_code", "code" to code,
69+
"redirect_uri" to request.redirectUri, "code_verifier" to request.codeVerifier))
70+
}
71+
72+
suspend fun refresh(refreshToken: String): MicrosoftOAuthTokens {
73+
require(refreshToken.isNotBlank())
74+
return token(mapOf("grant_type" to "refresh_token", "refresh_token" to refreshToken))
75+
}
76+
77+
private suspend fun token(fields: Map<String, String>): MicrosoftOAuthTokens {
78+
val values = linkedMapOf(
79+
"client_id" to applicationId, "scope" to "XboxLive.signin offline_access")
80+
values.putAll(fields)
81+
return try {
82+
val result = client.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token") {
83+
contentType(ContentType.Application.FormUrlEncoded)
84+
setBody(values.entries.joinToString("&") { enc(it.key) + "=" + enc(it.value) })
85+
}
86+
val parsed = json.decodeFromString<OAuthResponse>(result.bodyAsText())
87+
MicrosoftOAuthTokens(parsed.access, parsed.refresh)
88+
} catch (cancelled: CancellationException) {
89+
throw cancelled
90+
} catch (_: ResponseException) {
91+
throw IllegalStateException("Microsoft rejected this authentication request. Sign in again.")
92+
} catch (_: Exception) {
93+
throw IllegalStateException("Microsoft authentication failed. Check the connection and app registration.")
94+
}
95+
}
96+
97+
private fun nonce(): String = ByteArray(32).also(random::nextBytes).let {
98+
Base64.getUrlEncoder().withoutPadding().encodeToString(it)
99+
}
100+
private fun enc(s: String) = URLEncoder.encode(s, StandardCharsets.UTF_8)
101+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.account
2+
3+
import java.time.Instant
4+
import java.util.UUID
5+
import pl.syntaxdevteam.syntaxlauncher.core.launch.LaunchAuthentication
6+
7+
/** A premium identity must come from a verified Minecraft Java profile. */
8+
data class PremiumAccount(override val username: String, override val uuid: UUID) : Account {
9+
init { require(Regex("[A-Za-z0-9_]{3,16}").matches(username)) }
10+
11+
companion object {
12+
fun fromProfile(id: String, name: String): PremiumAccount {
13+
require(Regex("[0-9a-fA-F]{32}").matches(id))
14+
return PremiumAccount(name, UUID.fromString(id.replace(
15+
Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5")))
16+
}
17+
}
18+
}
19+
20+
/** A non-data class avoids accidental leaks through generated toString/copy. */
21+
class PremiumSession(
22+
val account: PremiumAccount,
23+
val minecraftToken: String,
24+
val expiresAt: Instant,
25+
val microsoftRefreshToken: String?,
26+
) {
27+
init { require(minecraftToken.isNotBlank()) }
28+
fun launchAuthentication(clientId: String): LaunchAuthentication =
29+
LaunchAuthentication(
30+
accessToken = minecraftToken,
31+
sessionId = "token:" + minecraftToken + ":" + account.uuid.toString().replace("-", ""),
32+
userType = "msa",
33+
clientId = clientId,
34+
)
35+
override fun toString() = "PremiumSession([REDACTED])"
36+
}

‎launcher-core/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/core/launch/LaunchModels.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package pl.syntaxdevteam.syntaxlauncher.core.launch
22

33
import pl.syntaxdevteam.syntaxlauncher.core.account.Account
4+
import pl.syntaxdevteam.syntaxlauncher.core.account.PremiumAccount
45
import pl.syntaxdevteam.syntaxlauncher.core.java.JavaRuntime
56
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.MinecraftPlatform
67
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.PreparedMinecraftVersion
@@ -26,8 +27,11 @@ data class LaunchAuthentication(
2627
val clientId: String = "",
2728
val xuid: String = "",
2829
) {
30+
override fun toString() = "LaunchAuthentication(type=" + userType + ", credentials=[REDACTED])"
31+
2932
companion object {
3033
fun offline(account: Account): LaunchAuthentication {
34+
require(account !is PremiumAccount) { "Premium account requires a verified session." }
3135
val compactUuid = account.uuid.toString().replace("-", "")
3236
return LaunchAuthentication(
3337
accessToken = "0",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.account
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.engine.mock.MockEngine
5+
import io.ktor.client.engine.mock.respond
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertFalse
9+
import kotlin.test.assertFailsWith
10+
import kotlin.test.assertTrue
11+
import kotlinx.coroutines.runBlocking
12+
13+
class MicrosoftOAuthTest {
14+
private val id = "12345678-1234-1234-1234-123456789abc"
15+
16+
@Test fun pkceStateAndVerifierAreFreshAndNotPrinted() {
17+
val client = HttpClient(MockEngine { respond("{}") })
18+
try {
19+
val auth = MicrosoftOAuth(client, id)
20+
val first = auth.begin("http://localhost:39891/callback")
21+
val second = auth.begin("http://localhost:39891/callback")
22+
assertTrue(first.url.contains("code_challenge_method=S256"))
23+
assertTrue(first.url.contains("XboxLive.signin"))
24+
assertTrue(first.url.contains("state="))
25+
assertFalse(first.url.contains("client_secret"))
26+
assertTrue(first.state != second.state)
27+
assertTrue(first.codeVerifier != second.codeVerifier)
28+
assertFalse(first.toString().contains(first.codeVerifier))
29+
assertFailsWith<IllegalArgumentException> {
30+
auth.begin("https://untrusted.example/callback")
31+
}
32+
} finally { client.close() }
33+
}
34+
35+
@Test fun tokenExchangeAndRefreshAreRedacted() = runBlocking {
36+
var calls = 0
37+
val client = HttpClient(MockEngine {
38+
calls++
39+
respond("""{"access_token":"private-access","refresh_token":"private-refresh"}""")
40+
})
41+
try {
42+
val auth = MicrosoftOAuth(client, id)
43+
val request = auth.begin("http://localhost:39891/callback")
44+
val token = auth.exchange("test-code", request)
45+
assertEquals("private-access", token.accessToken)
46+
assertFalse(token.toString().contains("private"))
47+
assertEquals("private-refresh", auth.refresh(token.refreshToken!!).refreshToken)
48+
assertEquals(2, calls)
49+
} finally { client.close() }
50+
}
51+
52+
@Test fun premiumProfileValidationAndSessionDoNotLogToken() {
53+
assertFailsWith<IllegalArgumentException> {
54+
PremiumAccount.fromProfile("not-a-uuid", "PremiumSteve")
55+
}
56+
val account = PremiumAccount.fromProfile("12345678123412341234123456789abc", "PremiumSteve")
57+
val session = PremiumSession(account, "private-token", java.time.Instant.now(), null)
58+
assertEquals("msa", session.launchAuthentication(id).userType)
59+
assertFalse(session.toString().contains("private-token"))
60+
}
61+
}

0 commit comments

Comments
 (0)