├── .github ├── FUNDING.yml └── workflows │ └── update_docs.yml ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── publish.gradle.kts ├── .gitignore ├── .idea └── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── src ├── commonMain │ ├── kotlin │ │ └── me │ │ │ └── nullicorn │ │ │ └── msmca │ │ │ ├── util │ │ │ └── Url.kt │ │ │ ├── http │ │ │ ├── Headers.kt │ │ │ ├── HttpException.kt │ │ │ ├── HttpClient.kt │ │ │ ├── Request.kt │ │ │ └── Response.kt │ │ │ ├── json │ │ │ ├── JsonMappingException.kt │ │ │ ├── EmptyJsonObjectView.kt │ │ │ ├── EmptyJsonArrayView.kt │ │ │ ├── JsonMapper.kt │ │ │ ├── JsonArrayView.kt │ │ │ └── JsonObjectView.kt │ │ │ ├── AuthException.kt │ │ │ ├── minecraft │ │ │ ├── MinecraftAuthException.kt │ │ │ ├── MinecraftXboxTokenRequest.kt │ │ │ ├── MinecraftToken.kt │ │ │ └── MinecraftAuth.kt │ │ │ ├── xbox │ │ │ ├── XboxLiveAuthException.kt │ │ │ ├── XboxLiveTokenRequest.kt │ │ │ ├── XboxLiveToken.kt │ │ │ ├── XboxLiveAuth.kt │ │ │ └── XboxLiveError.kt │ │ │ └── README.md │ └── resources │ │ ├── package_docs │ │ ├── Xbox.md │ │ ├── Minecraft.md │ │ └── Overview.md │ │ └── samples │ │ ├── Xbox.kt │ │ └── Minecraft.kt ├── jsMain │ └── kotlin │ │ └── me │ │ └── nullicorn │ │ └── msmca │ │ ├── json │ │ ├── JsJsonObjectView.kt │ │ ├── JsJsonArrayView.kt │ │ ├── JsJsonElementView.kt │ │ ├── JsonMapper.kt │ │ └── MapToJson.kt │ │ ├── util │ │ └── Url.kt │ │ ├── interop │ │ ├── NodeFetch.kt │ │ └── Iteration.kt │ │ └── http │ │ └── HttpClient.kt ├── commonTest │ └── kotlin │ │ └── me │ │ └── nullicorn │ │ └── msmca │ │ ├── mock │ │ ├── MockTokens.kt │ │ ├── MutableResponse.kt │ │ ├── MockHttpClient.kt │ │ └── MockHttpResponses.kt │ │ ├── util │ │ ├── Assertions.kt │ │ └── JsTestNames.kt │ │ └── xbox │ │ └── XboxLiveAuthTests.kt └── jvmMain │ └── kotlin │ ├── util │ └── Url.kt │ ├── json │ ├── SimpleJsonObjectView.kt │ ├── SimpleJsonArrayView.kt │ ├── SimpleJsonElementView.kt │ └── JsonMapper.kt │ └── http │ ├── HttpClient.kt │ └── HttpUrlConnection.kt ├── settings.gradle.kts ├── gradle.properties ├── LICENSE.md ├── gradlew.bat ├── README.md └── gradlew /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [TheNullicorn] 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNullicorn/ms-to-mca/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project Files 2 | .idea 3 | .gradle 4 | 5 | # Build Files 6 | build 7 | **/kotlin-js-store 8 | 9 | # Mac Files 10 | .DS_STORE -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/util/Url.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.util 2 | 3 | /** 4 | * Indicates whether the string represents a valid URL. 5 | */ 6 | internal expect val String.isUrl: Boolean -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/http/Headers.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.http 2 | 3 | /** 4 | * Keys-value pairs (both strings) that are either sent in an HTTP [Request], or received in an HTTP 5 | * [Response]. 6 | */ 7 | typealias Headers = Map -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/json/JsonMappingException.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | /** 4 | * Indicates that a JSON string could not be deserialized, or vice versa. 5 | */ 6 | internal class JsonMappingException( 7 | override val message: String?, 8 | override val cause: Throwable? = null, 9 | ) : Exception() -------------------------------------------------------------------------------- /src/jsMain/kotlin/me/nullicorn/msmca/json/JsJsonObjectView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | import kotlin.js.Json 4 | 5 | internal class JsJsonObjectView(private val actual: Json) : JsonObjectView { 6 | 7 | override fun get(key: String) = actual[key]?.jsonView 8 | 9 | override fun toString() = JSON.stringify(actual) 10 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/me/nullicorn/msmca/util/Url.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.util 2 | 3 | import org.w3c.dom.url.URL 4 | 5 | internal actual val String.isUrl: Boolean 6 | get() = try { 7 | URL(this) 8 | // If nothing was thrown, it's a good URL. 9 | true 10 | } catch (cause: Throwable) { 11 | false 12 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/me/nullicorn/msmca/mock/MockTokens.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.mock 2 | 3 | /** 4 | * Sample OAuth tokens (usually JWTs) that can be passed to the library to test its handling of 5 | * them. 6 | */ 7 | object MockTokens { 8 | // Decoded is just "header.payload.signature" 9 | const val SIMPLE = "aGVhZGVy.cGF5bG9hZA.c2lnbmF0dXJl" 10 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/http/HttpException.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.http 2 | 3 | /** 4 | * Thrown when an HTTP exception closes unexpectedly, or when the server's response is not a valid 5 | * HTTP response. 6 | */ 7 | internal class HttpException( 8 | override val message: String?, 9 | override val cause: Throwable? = null, 10 | ) : Exception() -------------------------------------------------------------------------------- /src/jsMain/kotlin/me/nullicorn/msmca/json/JsJsonArrayView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | internal class JsJsonArrayView(private val actual: Array<*>) : JsonArrayView { 4 | override val length: Int 5 | get() = actual.size 6 | 7 | override fun get(index: Int) = actual[index]?.jsonView 8 | 9 | override fun toString() = JSON.stringify(actual) 10 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/util/Url.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.util 2 | 3 | import java.net.MalformedURLException 4 | import java.net.URL 5 | 6 | internal actual val String.isUrl: Boolean 7 | get() = try { 8 | URL(this) 9 | // If nothing was thrown, it's a good URL. 10 | true 11 | } catch (cause: MalformedURLException) { 12 | false 13 | } -------------------------------------------------------------------------------- /src/commonMain/resources/package_docs/Xbox.md: -------------------------------------------------------------------------------- 1 | # Package me.nullicorn.msmca.xbox 2 | 3 | Tools for accessing the Xbox Live portion of the authentication process. For a less involved flow, 4 | see [me.nullicorn.msmca.minecraft]. 5 | 6 | [me.nullicorn.msmca.minecraft]: https://msmca.docs.nullicorn.me/ms-to-mca/me.nullicorn.msmca.minecraft 7 | 8 | @sample samples.Xbox.authViaMicrosoft 9 | 10 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "ms-to-mca" 2 | 3 | pluginManagement { 4 | val kotlinVersion = settings.extra["kotlin.version"] as? String 5 | ?: throw IllegalStateException("Please specify kotlin.version in gradle.properties") 6 | 7 | plugins { 8 | kotlin("multiplatform") version kotlinVersion 9 | id("org.jetbrains.dokka") version kotlinVersion 10 | } 11 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/json/SimpleJsonObjectView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | import com.github.cliftonlabs.json_simple.JsonObject 4 | 5 | /** 6 | * An unmodifiable view of a json-simple [JsonObject]. 7 | */ 8 | internal class SimpleJsonObjectView(private val actual: JsonObject) : JsonObjectView { 9 | 10 | override fun get(key: String) = actual[key]?.jsonView 11 | 12 | override fun toString() = actual.toString() 13 | } -------------------------------------------------------------------------------- /src/commonMain/resources/package_docs/Minecraft.md: -------------------------------------------------------------------------------- 1 | # Package me.nullicorn.msmca.minecraft 2 | 3 | Tools for accessing the Minecraft portion of the authentication process. If you also need access to 4 | the intermediary Xbox Live login process for any reason, see [me.nullicorn.msmca.xbox]. 5 | 6 | [me.nullicorn.msmca.xbox]: https://msmca.docs.nullicorn.me/ms-to-mca/me.nullicorn.msmca.xbox 7 | 8 | @sample samples.Minecraft.authViaMicrosoft 9 | 10 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/json/EmptyJsonObjectView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | /** 4 | * Represents a JSON object with no entries. 5 | * 6 | * This is intended as a fallback for when the server's response is empty, but an object was 7 | * expected. 8 | */ 9 | internal object EmptyJsonObjectView : JsonObjectView { 10 | 11 | override fun get(key: String): Nothing? = null 12 | 13 | override fun toString() = "{}" 14 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/json/EmptyJsonArrayView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | /** 4 | * Represents a JSON array with no elements. 5 | * 6 | * This is intended as a fallback for when the server's response is empty, but an array was 7 | * expected. 8 | */ 9 | internal object EmptyJsonArrayView : JsonArrayView { 10 | 11 | override val length = 0 12 | 13 | override fun get(index: Int): Nothing? = null 14 | 15 | override fun toString() = "[]" 16 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/me/nullicorn/msmca/json/JsJsonElementView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | import kotlin.js.Json 4 | 5 | internal val Any?.jsonView: Any? 6 | get() = when (this) { 7 | // JSON primitives stay as-is. 8 | is Number, is Boolean, is String, null -> this 9 | // Arrays & objects get wrapped in their respective view classes. 10 | is Array<*> -> JsJsonArrayView(this) 11 | else -> JsJsonObjectView(this.unsafeCast()) 12 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/me/nullicorn/msmca/interop/NodeFetch.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.interop 2 | 3 | import kotlin.js.Json 4 | 5 | @JsNonModule 6 | @JsModule("sync-fetch") 7 | @JsName("fetch") 8 | internal external fun fetch(url: String, options: Json): JsResponse 9 | 10 | internal external class JsResponse { 11 | val status: Int 12 | val headers: JsHeaders 13 | fun text(): String 14 | } 15 | 16 | internal external class JsHeaders { 17 | fun get(name: String): String 18 | fun keys(): JsIterator 19 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | name = ms-to-mca 2 | author.url = github.com/TheNullicorn 3 | 4 | repo.release.url = https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ 5 | repo.snapshot.url = https://s01.oss.sonatype.org/content/repositories/snapshots 6 | 7 | kotlin.version = 1.6.10 8 | kotlin.code.style = official 9 | kotlin.mpp.enableGranularSourceSetsMetadata = true 10 | kotlin.native.enableDependencyPropagation = false 11 | kotlin.js.generate.executable.default = false -------------------------------------------------------------------------------- /src/jvmMain/kotlin/json/SimpleJsonArrayView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | import com.github.cliftonlabs.json_simple.JsonArray 4 | 5 | /** 6 | * An unmodifiable view of a json-simple [JsonArray]. 7 | */ 8 | internal class SimpleJsonArrayView(private val actual: JsonArray) : JsonArrayView { 9 | override val length: Int 10 | get() = actual.size 11 | 12 | override fun get(index: Int): Any? { 13 | if (index in 0 until actual.size) 14 | return actual[index]?.jsonView 15 | 16 | return null 17 | } 18 | 19 | override fun toString() = actual.toString() 20 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/AuthException.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca 2 | 3 | /** 4 | * Indicates that some form of authentication failed. 5 | * 6 | * Causes could include connection issues, user errors, and server errors. 7 | */ 8 | open class AuthException( 9 | message: String? = null, 10 | cause: Throwable? = null, 11 | ) : Exception(message, cause) 12 | 13 | /* 14 | * Until KT-43490 is fixed, `message` and `cause` have to be regular parameters passed into 15 | * Exception's constructor (as opposed to using `override val` for each). 16 | * 17 | * See https://youtrack.jetbrains.com/issue/KT-43490 18 | */ -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/minecraft/MinecraftAuthException.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.minecraft 2 | 3 | import me.nullicorn.msmca.AuthException 4 | 5 | /** 6 | * Thrown when Minecraft's authentication service returns a valid response, but one that indicates 7 | * an error. 8 | * 9 | * @param[type] The value of the `errorType` field returned by the service, if present. This is 10 | * typically *not* a user-friendly message, but explains to the developer what went wrong. 11 | */ 12 | class MinecraftAuthException( 13 | val type: String?, 14 | cause: Throwable? = null, 15 | ) : AuthException("Xbox Live returned an error: $type", cause) -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/xbox/XboxLiveAuthException.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.xbox 2 | 3 | import me.nullicorn.msmca.AuthException 4 | 5 | /** 6 | * Thrown when an Xbox Live service returns a valid response, but one that indicates an error. 7 | * 8 | * It's recommended to display the [reason] to users in a friendly format to help them troubleshoot 9 | * the issue. It may often be due to user error, or a server issue out of the user's control. 10 | * 11 | * @param[reason] The error returned by the service. 12 | */ 13 | class XboxLiveAuthException(val reason: XboxLiveError = XboxLiveError.UNKNOWN) : 14 | AuthException("Xbox Live returned an error: $reason") -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/README.md: -------------------------------------------------------------------------------- 1 | ### Package Overview 2 | 3 | --- 4 | 5 | `http` - Abstraction layer over an HTTP client, which is used internally for requests sent to 6 | Microsoft and Mojang servers. 7 | 8 | `json` - Abstraction layer over JSON objects, arrays, encoding and decoding. This is used internally 9 | to parse and interpret responses received from the `http` package. 10 | 11 | `minecraft` A minimal implementation of a Minecraft authentication client. Only areas of the API 12 | related to Xbox Live login are implemented. 13 | 14 | `util` - Miscellaneous helpers that don't fit into any of the other packages. 15 | 16 | `xbox` - A minimal implementation of an Xbox Live authentication client. Xbox Live is what allows us 17 | to turn Microsoft access tokens into Mojang/Minecraft access tokens at the end of the process. -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/http/HttpClient.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.http 2 | 3 | /** 4 | * A simple HTTP client used to access online services for Xbox Live & Minecraft. 5 | */ 6 | interface HttpClient { 7 | /** 8 | * Synchronously sends an HTTP [request] to the server, and awaits the [response][Response]. 9 | * 10 | * @param[request] The HTTP method, URL, headers, and (optional) body to send to the server. 11 | * @return The HTTP status, headers, and body that the server sent in response. 12 | * 13 | * @throws[HttpException] if the connection fails, or if the server sends a malformed response. 14 | */ 15 | fun send(request: Request): Response 16 | } 17 | 18 | internal expect object BuiltInHttpClient : HttpClient { 19 | actual override fun send(request: Request): Response 20 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/http/Request.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.http 2 | 3 | /** 4 | * An HTTP request with an optional JSON body. 5 | */ 6 | interface Request { 7 | /** 8 | * The host and path that the request should be sent to. 9 | * 10 | * Format: ```[scheme]://[host][:port]/[path]``` 11 | */ 12 | val url: String 13 | 14 | /** 15 | * The HTTP method that should be used to send the request. 16 | * 17 | * e.g. `GET`, `POST`, `PUT`, etc. 18 | */ 19 | val method: String 20 | 21 | /** 22 | * The HTTP [Headers] that should be sent before the request's [body]. 23 | */ 24 | val headers: Headers 25 | get() = emptyMap() 26 | 27 | /** 28 | * Any extra information to include in the request. (Optional) 29 | * 30 | * This will be serialized as JSON if/when the request is sent. 31 | */ 32 | val body: Map? 33 | get() = null 34 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/minecraft/MinecraftXboxTokenRequest.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.minecraft 2 | 3 | import me.nullicorn.msmca.http.Request 4 | import me.nullicorn.msmca.xbox.XboxLiveAuth 5 | import me.nullicorn.msmca.xbox.XboxLiveToken 6 | 7 | /** 8 | * Internal request used to authenticate with Minecraft using Xbox. 9 | * 10 | * @param[xboxToken] an Xbox Live [service token][XboxLiveAuth.getServiceToken]. 11 | */ 12 | internal class MinecraftXboxTokenRequest(xboxToken: XboxLiveToken) : Request { 13 | override val method = "POST" 14 | 15 | override val url = "https://api.minecraftservices.com/authentication/login_with_xbox" 16 | 17 | override val headers: Map 18 | get() = mapOf( 19 | "accept" to "application/json", 20 | "content-type" to "application/json", 21 | ) 22 | 23 | override val body: Map = mapOf( 24 | "identityToken" to "XBL3.0 x=${xboxToken.user};${xboxToken.value}" 25 | ) 26 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/me/nullicorn/msmca/mock/MutableResponse.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.mock 2 | 3 | import me.nullicorn.msmca.http.Response 4 | 5 | /** 6 | * A mutable version of [Response]. 7 | * 8 | * Useful for testing the library's handling of various HTTP responses from the services it used. 9 | */ 10 | data class MutableResponse( 11 | var status: Int = 200, 12 | var headers: MutableMap = mutableMapOf(), 13 | var token: String = MockTokens.SIMPLE, 14 | var userHash: String = "0", 15 | var body: String = """ 16 | { 17 | "Token": "$token", 18 | "DisplayClaims": { 19 | "xui": [ 20 | { 21 | "uhs": "$userHash" 22 | } 23 | ] 24 | } 25 | } 26 | """.trimIndent(), 27 | ) { 28 | /** 29 | * Creates an immutable copy of the response based on its current state. 30 | */ 31 | fun toResponse() = Response(status, headers.toMap(), body) 32 | } -------------------------------------------------------------------------------- /.github/workflows/update_docs.yml: -------------------------------------------------------------------------------- 1 | name: Update Documentation Site 2 | 3 | # Trigger manually, or whenever the main branch is updated. 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | # Steps represent a sequence of tasks that will be executed as part of the job 14 | steps: 15 | - name: Checkout main branch 16 | uses: actions/checkout@v2.4.0 17 | 18 | - name: Define OSSRH credentials 19 | run: | 20 | mkdir -p ~/.gradle 21 | touch ~/.gradle/gradle.properties 22 | echo 'ossrhUsername = ${{ secrets.OSSRH_USERNAME }}' >> ~/.gradle/gradle.properties 23 | echo 'ossrhPassword = ${{ secrets.OSSRH_PASSWORD }}' >> ~/.gradle/gradle.properties 24 | 25 | - name: Generate HTML docs 26 | run: ./gradlew dokkaHtml 27 | 28 | - name: Publish HTML docs 29 | uses: JamesIves/github-pages-deploy-action@v4.2.5 30 | with: 31 | folder: build/dokka/html 32 | branch: gh-pages 33 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/json/SimpleJsonElementView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | import com.github.cliftonlabs.json_simple.JsonArray 4 | import com.github.cliftonlabs.json_simple.JsonObject 5 | 6 | /** 7 | * Internal helper. Converts the current Gson object to either a JSON view, or the corresponding 8 | * built-in type (`null`, [String], [Number], or [Boolean]). 9 | * 10 | * @return - a [JsonObjectView] of the value, if it's an [object][JsonObject] 11 | * - a [JsonArrayView] of the value, if it's an [array][JsonArray] 12 | * - the value itself, if it's a [Number], [Boolean], [String], or `null`. 13 | * 14 | * @throws IllegalArgumentException if the value's runtime type is not JSON compatible. 15 | * 16 | * @see[JsonObjectView] 17 | * @see[JsonArrayView] 18 | */ 19 | internal val Any?.jsonView: Any? 20 | get() = when (this) { 21 | is String, is Number, is Boolean, null -> this 22 | is JsonArray -> SimpleJsonArrayView(this) 23 | is JsonObject -> SimpleJsonObjectView(this) 24 | else -> throw IllegalArgumentException("No viewable runtime type for $javaClass") 25 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 TheNullicorn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/me/nullicorn/msmca/interop/Iteration.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.interop 2 | 3 | internal external interface JsIterator { 4 | fun next(): JsIteration 5 | } 6 | 7 | internal external interface JsIteration { 8 | val done: Boolean? 9 | val value: T? 10 | } 11 | 12 | internal inline fun JsIterator.toKtIterator() = object : Iterator { 13 | val jsIterator = this@toKtIterator 14 | var lastIteration: JsIteration? = null 15 | 16 | override fun hasNext(): Boolean { 17 | return lastIteration?.done == false 18 | } 19 | 20 | override fun next(): T? { 21 | if (!hasNext()) throw NoSuchElementException("JS iterator is exhausted") 22 | 23 | lastIteration = jsIterator.next() 24 | 25 | // If the iterator is "done", throw unless the last iteration actually had a value. 26 | if (lastIteration?.done == true) { 27 | if (lastIteration?.value != null) 28 | return lastIteration?.value 29 | else throw NoSuchElementException("JS iterator is exhausted") 30 | } 31 | 32 | return lastIteration?.value?.unsafeCast() 33 | } 34 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/me/nullicorn/msmca/http/HttpClient.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.http 2 | 3 | import me.nullicorn.msmca.interop.JsHeaders 4 | import me.nullicorn.msmca.interop.JsResponse 5 | import me.nullicorn.msmca.interop.fetch 6 | import me.nullicorn.msmca.interop.toKtIterator 7 | import me.nullicorn.msmca.json.JsonMapper 8 | import me.nullicorn.msmca.json.toJson 9 | import kotlin.js.json 10 | 11 | internal actual object BuiltInHttpClient : HttpClient { 12 | 13 | actual override fun send(request: Request): Response { 14 | val options = json( 15 | "method" to request.method, 16 | "body" to JsonMapper.stringify(request.body?.toJson()), 17 | "headers" to json(*request.headers.toList().toTypedArray()) 18 | ) 19 | 20 | return fetch(request.url, options).toKtResponse() 21 | } 22 | 23 | private fun JsHeaders.toKtHeaders() = buildMap { 24 | val jsHeaders = this@toKtHeaders 25 | 26 | for (key in keys().toKtIterator()) { 27 | if (key == null) continue 28 | set(key, jsHeaders.get(key)) 29 | } 30 | } 31 | 32 | private fun JsResponse.toKtResponse() = 33 | Response(status, headers.toKtHeaders(), text()) 34 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/me/nullicorn/msmca/json/JsonMapper.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | import kotlin.js.Json 4 | 5 | internal actual object JsonMapper { 6 | 7 | actual fun parseObject(jsonString: String): JsonObjectView = 8 | JsJsonObjectView(jsonString.toJson().unsafeCast()) 9 | 10 | actual fun parseArray(jsonString: String): JsonArrayView = 11 | JsJsonArrayView(jsonString.toJson()) 12 | 13 | actual fun stringify(input: Any?): String = try { 14 | JSON.stringify(input) 15 | } catch (cause: Throwable) { 16 | throw JsonMappingException("Failed to stringify using JSON.stringify", cause) 17 | } 18 | } 19 | 20 | /** 21 | * Internal helper. Attempts to parse the string, using Gson, as a JSON value of type [T]. 22 | * 23 | * @param[T] The Gson type that the output is expected to be. 24 | * 25 | * @throws[JsonMappingException] if the string does not represent valid JSON. 26 | * @throws[JsonMappingException] if the string, when parsed as JSON, is a different type than [T]. 27 | */ 28 | private inline fun String.toJson(): T { 29 | val parsed = try { 30 | JSON.parse(this) 31 | } catch (cause: Throwable) { 32 | throw JsonMappingException("Failed to parse using JSON.parse", cause) 33 | } 34 | 35 | return parsed 36 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/me/nullicorn/msmca/util/Assertions.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.util 2 | 3 | /** 4 | * Ensures that a [test] does not raise any exceptions. 5 | * 6 | * If the test fails/throws, an [AssertionError] is thrown, detailing the cause of the exception. 7 | * 8 | * @param[description] An optional message indicating what the test is asserting. It should be in 9 | * the present tense, such as... 10 | * ```text 11 | * "get a random number" 12 | * ``` 13 | * 14 | * @throws[AssertionError] if an exception was thrown by the [test]. 15 | */ 16 | inline fun assertSucceeds(description: String? = null, test: () -> Unit) { 17 | try { 18 | test() 19 | } catch (cause: Throwable) { 20 | // If failed, include the provided description in the message. 21 | // Otherwise, use a generic message starter. 22 | var errMessage = if (description != null) { 23 | "Failed to $description (${cause::class.simpleName})" 24 | } else { 25 | "Unexpected ${cause::class.simpleName} thrown" 26 | } 27 | 28 | // Append the cause's message if available. 29 | // For some reason AssertionError doesn't accept a `cause` argument on multiplatform. 30 | if (cause.message != null) 31 | errMessage += ": ${cause.message}" 32 | 33 | throw AssertionError(errMessage) 34 | } 35 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/me/nullicorn/msmca/json/MapToJson.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | import kotlin.js.Json 4 | import kotlin.js.json 5 | 6 | /** 7 | * Converts a Kotlin map into a generic JavaScript object, whose keys and values come from the 8 | * original map. 9 | * 10 | * This operation is recursive, converting any sub-maps into JavaScript objects, as well as any 11 | * [Iterable]s into JavaScript arrays. 12 | */ 13 | internal fun Map<*, *>.toJson(): Json { 14 | val result = json() 15 | 16 | for ((key, value) in this) { 17 | if (key == null) continue 18 | 19 | val safeKey = key.toString() 20 | val safeValue = when (value) { 21 | is Map<*, *> -> value.toJson() 22 | is Iterable<*> -> value.toJson() 23 | else -> value 24 | } 25 | 26 | result[safeKey] = safeValue 27 | } 28 | 29 | return result 30 | } 31 | 32 | /** 33 | * Behaves the same as [Map.toJson], but operates on an [Iterable] instead of a [Map] 34 | */ 35 | private fun Iterable<*>.toJson(): Array<*> { 36 | val result = mutableListOf() 37 | 38 | for (element in this) { 39 | val safeElement = when (element) { 40 | is Map<*, *> -> element.toJson() 41 | is Iterable<*> -> element.toJson() 42 | else -> element 43 | } 44 | 45 | result.add(safeElement) 46 | } 47 | 48 | return result.toTypedArray() 49 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/json/JsonMapper.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | /** 4 | * Maps JSON to and from its string form. 5 | */ 6 | internal expect object JsonMapper { 7 | /** 8 | * Attempts to parse a string as JSON, with the assumption that it represents an object. 9 | * 10 | * @param[jsonString] The JSON compliant string to parse. 11 | * @return the parsed object. 12 | * 13 | * @throws[JsonMappingException] if the [jsonString], when parsed, does not represent an object. 14 | * @throws[JsonMappingException] if the [jsonString] is malformed. 15 | */ 16 | fun parseObject(jsonString: String): JsonObjectView 17 | 18 | /** 19 | * Attempts to parse a string as JSON, with the assumption that it represents an array. 20 | * 21 | * @param[jsonString] The JSON compliant string to parse. 22 | * @return the parsed array. 23 | * 24 | * @throws[JsonMappingException] if the [jsonString], when parsed, does not represent an array. 25 | * @throws[JsonMappingException] if the [jsonString] is malformed. 26 | */ 27 | fun parseArray(jsonString: String): JsonArrayView 28 | 29 | /** 30 | * Attempts to convert an object to an equivalent JSON representation. 31 | * 32 | * @param[input] The object to convert to JSON. 33 | * @return the object's JSON representation. 34 | * 35 | * @throws[JsonMappingException] if the [input] cannot be mapped to JSON. 36 | */ 37 | fun stringify(input: Any?): String 38 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/json/JsonMapper.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | import com.github.cliftonlabs.json_simple.JsonException 4 | import com.github.cliftonlabs.json_simple.Jsoner 5 | 6 | internal actual object JsonMapper { 7 | 8 | actual fun parseObject(jsonString: String): JsonObjectView = 9 | SimpleJsonObjectView(jsonString.toJson()) 10 | 11 | actual fun parseArray(jsonString: String): JsonArrayView = 12 | SimpleJsonArrayView(jsonString.toJson()) 13 | 14 | actual fun stringify(input: Any?): String = try { 15 | Jsoner.serialize(input) 16 | } catch (cause: IllegalArgumentException) { 17 | throw JsonMappingException("Cannot serialize ${input?.javaClass} as JSON") 18 | } 19 | } 20 | 21 | /** 22 | * Internal helper. Attempts to parse the string, using Gson, as a JSON value of type [T]. 23 | * 24 | * @param[T] The Gson type that the output is expected to be. 25 | * 26 | * @throws[JsonMappingException] if the string does not represent valid JSON. 27 | * @throws[JsonMappingException] if the string, when parsed as JSON, is a different type than [T]. 28 | */ 29 | private inline fun String.toJson(): T { 30 | val parsed = try { 31 | Jsoner.deserialize(this) 32 | } catch (cause: JsonException) { 33 | throw JsonMappingException("Failed to parse using JsonParser.parseString", cause) 34 | } 35 | 36 | if (parsed !is T) throw JsonMappingException( 37 | "Expected JSON to be ${T::class.simpleName}, but got ${parsed.javaClass.simpleName}" 38 | ) 39 | 40 | return parsed 41 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/me/nullicorn/msmca/util/JsTestNames.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.util 2 | 3 | import kotlin.js.JsName 4 | 5 | /** 6 | * Nondescript names for the [@JsName][JsName] annotation, particularly on tests whose function names are 7 | * wrapped in backticks, e.g. `...`. 8 | * 9 | * Usage: 10 | * ```kotlin 11 | * @Test 12 | * @JsName(ONE) 13 | * fun `should do something really cool`() { 14 | * // Your awesome test here... 15 | * } 16 | * ``` 17 | * 18 | * # Explanation 19 | * 20 | * Kotlin/JS doesn't allow functions named with backticks to include special characters (spaces, 21 | * punctuation, symbols) unless you annotate those functions with @JsNames that include valid 22 | * function names. 23 | * 24 | * However, the IDE (or at least IntelliJ) still displays the test's correct name in the testing UI, 25 | * so the `@JsName` doesn't need to be anything meaningful. Hence, the values included here can be 26 | * used to give tests unique [@JsName][JsName] values. 27 | * 28 | * If you're contributing tests and need more unique names, feel free to add them as-needed. 29 | */ 30 | 31 | const val ONE = 32 | "_" 33 | 34 | const val TWO = 35 | "__" 36 | 37 | const val THREE = 38 | "___" 39 | 40 | const val FOUR = 41 | "____" 42 | 43 | const val FIVE = 44 | "_____" 45 | 46 | const val SIX = 47 | "______" 48 | 49 | const val SEVEN = 50 | "_______" 51 | 52 | const val EIGHT = 53 | "________" 54 | 55 | const val NINE = 56 | "_________" 57 | 58 | const val TEN = 59 | "__________" 60 | 61 | const val ELEVEN = 62 | "___________" 63 | 64 | const val TWELVE = 65 | "____________" -------------------------------------------------------------------------------- /src/commonMain/resources/package_docs/Overview.md: -------------------------------------------------------------------------------- 1 | # Module ms-to-mca 2 | 3 | Simplifies the process of logging into [Minecraft services](https://wiki.vg/Mojang_API) on behalf of 4 | a Microsoft user. In order to use this library, you will need to create an app that interfaces with 5 | the [Microsoft Identity Platform][ms-openid]. The easiest way is using an implementation of the 6 | Microsoft Authentication Library ([MSAL][msal-overview]). 7 | 8 | Once your app is set up, request an **access token** from the user (that's what you'll need to use this library). Be sure to ask for the `XboxLive.signin` scope! It's required for 9 | logging in to Minecraft. 10 | 11 | ### Disclaimer 12 | 13 | Firstly, **Minecraft access tokens can be dangerous!** Tokens grant an application **full permissions** to a user's account, meaning they can... 14 | - connect to online-mode servers on the user's behalf 15 | - change profile info... 16 | - username 17 | - capes 18 | - skins 19 | 20 | I **strongly** recommend you provide a similar disclaimer to users logging into your own app, both for their security and to raise awareness of the dangers of Minecraft authentication with third-party apps. 21 | 22 | Secondly, **this is NOT a Minecraft API library**. Its only function is exchanging Microsoft access tokens for Minecraft ones, which can be used to perform actions on behalf of a Minecraft account. To interface with the rest of Minecraft's online services you'll need a separate library of your choice. 23 | 24 | [ms-openid]: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc 25 | 26 | [msal-overview]: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-overview -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/http/Response.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.http 2 | 3 | import me.nullicorn.msmca.json.* 4 | 5 | /** 6 | * Information sent by a server in response to an HTTP [request][Request]. 7 | * 8 | * @param[status] The HTTP status code provided at the start of the response. 9 | * @param[headers] Any key-value pairs included at the start of the response. 10 | * @param[body] The main contents of the response. 11 | */ 12 | data class Response( 13 | val status: Int, 14 | val headers: Headers, 15 | val body: String, 16 | ) { 17 | /** 18 | * Whether the [status] code is in the range `200`-`299`, both inclusive. 19 | */ 20 | val isSuccess = (200..299).contains(status) 21 | 22 | /** 23 | * Attempts to parse the response's [body] as a JSON object. 24 | * 25 | * If the response is blank (empty, or only whitespace), an empty JSON object is returned. 26 | * 27 | * @return the parsed object. 28 | * 29 | * @throws[JsonMappingException] if the response's body is not valid JSON, or if it is valid 30 | * JSON but not an *object*. 31 | * 32 | * @see[JsonMapper.parseObject] 33 | */ 34 | internal fun asJsonObject(): JsonObjectView = if (body.isNotBlank()) { 35 | JsonMapper.parseObject(body) 36 | } else EmptyJsonObjectView 37 | 38 | /** 39 | * Attempts to parse the response's [body] as a JSON array. 40 | * 41 | * If the response is blank (empty, or only whitespace), an empty JSON array is returned. 42 | * 43 | * @return the parsed array. 44 | * @throws[JsonMappingException] if the response's body is not valid JSON, or if it is valid 45 | * JSON but not an *array*. 46 | * 47 | * @see[JsonMapper.parseArray] 48 | */ 49 | internal fun asJsonArray(): JsonArrayView = if (body.isNotBlank()) { 50 | JsonMapper.parseArray(body) 51 | } else EmptyJsonArrayView 52 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/minecraft/MinecraftToken.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.minecraft 2 | 3 | import me.nullicorn.msmca.json.JsonObjectView 4 | 5 | /** 6 | * A token that can be used to access Minecraft services on a player's behalf. 7 | * 8 | * @param[type] The authorization scheme to be used with the token, such as "`Bearer`" or "`Basic`". 9 | * @param[value] The value of the token itself. Typically, this is in JSON Web Token (JWT) format. 10 | * @param[user] The UUID of the Minecraft account that the token is intended for. **This UUID is 11 | * associated with the account itself, not the player that belongs to the account.** 12 | * @param[duration] The amount of time, in seconds, that the token lasts for. 13 | */ 14 | data class MinecraftToken( 15 | val type: String, 16 | val value: String, 17 | val user: String, 18 | val duration: Int, 19 | ) { 20 | /** 21 | * Deserializes a JSON token received from Minecraft's Xbox login endpoint (see below). 22 | * 23 | * ```text 24 | * POST https://api.minecraftservices.com/authentication/login_with_xbox 25 | * ``` 26 | * 27 | * @throws[IllegalArgumentException] if the [responseJson] is missing any required fields. 28 | */ 29 | internal constructor(responseJson: JsonObjectView) : this( 30 | type = responseJson.getString("type") ?: "Bearer", 31 | 32 | value = responseJson.getString("access_token") 33 | ?: throw IllegalArgumentException("Token's value is missing"), 34 | 35 | user = responseJson.getString("username") 36 | ?: throw IllegalArgumentException("Token's user ID is missing"), 37 | 38 | duration = responseJson.getNumber("expires_in")?.toInt() 39 | ?: throw IllegalArgumentException("Token's duration is missing") 40 | ) 41 | 42 | // Omit the token's "value" to prevent someone from accidentally logging it. 43 | override fun toString() = "MinecraftToken(type='$type', user='$user', duration=$duration)" 44 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/xbox/XboxLiveTokenRequest.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.xbox 2 | 3 | import me.nullicorn.msmca.http.Request 4 | 5 | /** 6 | * Internal request used to authenticate & generate an Xbox Live API token. 7 | * 8 | * @param[context] The Xbox Live API to use, such as `user` or `xsts`. 9 | * @param[endpoint] The API endpoint to send the request to. 10 | * @param[relyingParty] The URI of the third party that the token is intended for. 11 | * @param[properties] Arbitrary data to include in the request's JSON body. 12 | */ 13 | internal data class XboxLiveTokenRequest( 14 | val context: String, 15 | val endpoint: String, 16 | val relyingParty: String, 17 | val properties: Map, 18 | ) : Request { 19 | override val url: String 20 | get() = "https://$context.auth.xboxlive.com/$context/$endpoint" 21 | 22 | override val method: String 23 | get() = "POST" 24 | 25 | override val headers: Map 26 | get() = mapOf( 27 | "accept" to "application/json", 28 | "content-type" to "application/json", 29 | ) 30 | 31 | override val body: Map 32 | get() = mapOf( 33 | "TokenType" to "JWT", 34 | "Properties" to properties, 35 | "RelyingParty" to relyingParty, 36 | ) 37 | 38 | companion object { 39 | /** 40 | * Creates a request for an Xbox Live user token. 41 | * 42 | * @param[microsoftToken] A general-purpose Microsoft access token, received from OAuth or OpenID authentication. 43 | */ 44 | @Suppress("HttpUrlsUsage") 45 | fun user(microsoftToken: String) = XboxLiveTokenRequest( 46 | context = "user", 47 | endpoint = "authenticate", 48 | relyingParty = "http://auth.xboxlive.com", 49 | properties = mapOf( 50 | "SiteName" to "user.auth.xboxlive.com", 51 | "RpsTicket" to "d=$microsoftToken", 52 | "AuthMethod" to "RPS", 53 | ), 54 | ) 55 | 56 | fun xsts(userToken: String) = XboxLiveTokenRequest( 57 | context = "xsts", 58 | endpoint = "authorize", 59 | relyingParty = "rp://api.minecraftservices.com/", 60 | properties = mapOf( 61 | "SandboxId" to "RETAIL", 62 | "UserTokens" to Array(size = 1) { userToken }, 63 | ), 64 | ) 65 | } 66 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/json/JsonArrayView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | /** 4 | * An unmodifiable view of a JSON array. 5 | */ 6 | internal interface JsonArrayView : Iterable { 7 | 8 | /** 9 | * The number of elements in the array. 10 | */ 11 | val length: Int 12 | 13 | /** 14 | * Retrieves the array element at the specified [index]. 15 | * 16 | * For [strings][String], [numbers][Number], and [booleans][Boolean], (JSON "primitives") this 17 | * will return the value itself. 18 | * 19 | * However, for objects and arrays, this will return a [JsonObjectView] or [JsonArrayView] 20 | * respectively, which provides a layer of abstraction over the underlying structure. 21 | * 22 | * @param[index] The zero-based array index for the desired element. 23 | * @return the element at that index. May be null if the [index] is out of bounds, or if the 24 | * element at that [index] is explicitly set to `null`. 25 | * @see[getObject] 26 | * @see[getArray] 27 | * @see[getNumber] 28 | * @see[getString] 29 | * @see[getBoolean] 30 | */ 31 | operator fun get(index: Int): Any? 32 | 33 | /** 34 | * Shorthand function for `get(index) as? JsonObjectView`. 35 | * @see[get] 36 | */ 37 | fun getObject(index: Int): JsonObjectView? = get(index) as? JsonObjectView 38 | 39 | /** 40 | * Shorthand function for `get(index) as? JsonArrayView`. 41 | * @see[get] 42 | */ 43 | fun getArray(index: Int): JsonArrayView? = get(index) as? JsonArrayView 44 | 45 | /** 46 | * Shorthand function for `get(index) as? Number`. 47 | * @see[get] 48 | */ 49 | fun getNumber(index: Int): Number? = get(index) as? Number 50 | 51 | /** 52 | * Shorthand function for `get(index) as? String`. 53 | * @see[get] 54 | */ 55 | fun getString(index: Int): String? = get(index) as? String 56 | 57 | /** 58 | * Shorthand function for `get(index) as? Boolean`. 59 | * @see[get] 60 | */ 61 | fun getBoolean(index: Int): Boolean? = get(index) as? Boolean 62 | 63 | override fun iterator(): Iterator { 64 | return object : Iterator { 65 | private var index = 0 66 | 67 | override fun hasNext() = index < length 68 | 69 | override fun next() = if (hasNext()) get(index++) 70 | else throw NoSuchElementException("No more elements in iterator") 71 | } 72 | } 73 | 74 | override fun toString(): String 75 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/xbox/XboxLiveToken.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.xbox 2 | 3 | import me.nullicorn.msmca.json.JsonObjectView 4 | 5 | /** 6 | * Credentials required to authenticate with Xbox Live and other linked services. 7 | * 8 | * @param[value] The value of the token, used to authenticate with Xbox Live APIs. 9 | * @param[user] The "hash" of the user who the token was generated for. Not to be confused with the 10 | * user's actual ID, `XUID`. 11 | */ 12 | data class XboxLiveToken( 13 | val value: String, 14 | val user: String, 15 | ) { 16 | /** 17 | * Deserializes a JSON token received from an Xbox Live authentication service. 18 | * 19 | * @throws[IllegalArgumentException] if the [responseJson] is missing any required fields. 20 | */ 21 | internal constructor(responseJson: JsonObjectView) : this( 22 | value = responseJson.tokenValue 23 | ?: throw IllegalArgumentException("Token's value is missing"), 24 | 25 | user = responseJson.userHash 26 | ?: throw IllegalArgumentException("Token's user hash is missing") 27 | ) 28 | 29 | // Omit the token's "value" to prevent someone from accidentally logging it. 30 | override fun toString() = "XboxLiveToken(user='$user')" 31 | 32 | private companion object { 33 | 34 | /** 35 | * Retrieves the acces token value from the following JSON path in the response: 36 | * ```text 37 | * Token 38 | * ``` 39 | * May be `null` if the value is missing, or if it isn't a string. 40 | */ 41 | val JsonObjectView.tokenValue: String? 42 | get() = getString("Token") 43 | 44 | /** 45 | * Retrieves the user hash value (`"uhs"`) from the following JSON path in the response: 46 | * ```text 47 | * DisplayClaims.xui[i].uhs 48 | * ``` 49 | * ...Where `i` is the index of the first object in `xui` that contains a string field 50 | * named `uhs` 51 | * 52 | * May be `null` if the value (or any of its parent elements) are missing, or if it isn't a 53 | * string. 54 | */ 55 | val JsonObjectView.userHash: String? 56 | get() { 57 | val xuiClaims = getObject("DisplayClaims")?.getArray("xui") 58 | ?: return null 59 | 60 | for (i in 0 until xuiClaims.length) 61 | return xuiClaims.getObject(i)?.getString("uhs") ?: continue 62 | 63 | return null 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/http/HttpClient.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.http 2 | 3 | import me.nullicorn.msmca.util.isUrl 4 | import java.io.IOException 5 | import java.net.HttpURLConnection 6 | import java.net.MalformedURLException 7 | import java.net.URL 8 | 9 | /** 10 | * Java implementation of an HTTP client, using the built-in [HttpURLConnection] class. 11 | */ 12 | internal actual object BuiltInHttpClient : HttpClient { 13 | 14 | actual override fun send(request: Request): Response { 15 | // Get request.url once so that this client can't be tricked by the getter. 16 | // (e.g. the getter returns a valid url for this check, but not for the actual request). 17 | val url = request.url 18 | if (!url.isUrl) throw IllegalArgumentException("Cannot request to malformed URL: $url") 19 | 20 | // Attempt to create a new connection to the request's URL. 21 | val connection = try { 22 | URL(url).openHttpConnection() 23 | } catch (cause: MalformedURLException) { 24 | throw HttpException("Invalid URL: \"$url\"", cause) 25 | } 26 | 27 | // Add the request's method, headers, and body to the connection. 28 | connection.configure(request) 29 | 30 | // Execute the request & read the response. 31 | return connection.response 32 | } 33 | } 34 | 35 | /** 36 | * Creates a connection to the URL using the HTTP or HTTPS protocol (whichever the URL 37 | * [specifies][URL.protocol]). 38 | * 39 | * This method does not [establish][HttpURLConnection.connect] the connection, just creates a new 40 | * connection instance to be configured. 41 | * 42 | * @throws HttpException if the URL does not use either the HTTP or HTTPS scheme. 43 | * @throws HttpException if the connection could not be created for some I/O-related reason 44 | * (specified in the exception's [cause][HttpException.cause]). 45 | */ 46 | private fun URL.openHttpConnection(): HttpURLConnection { 47 | val scheme = protocol.lowercase() 48 | if (scheme != "https" && scheme != "http") { 49 | throw HttpException("URL must use scheme https or http, not $scheme") 50 | } 51 | 52 | try { 53 | return openConnection() as HttpURLConnection 54 | 55 | } catch (cause: IOException) { 56 | // Caught if the connection could not be created. 57 | throw HttpException("Failed to open connection to $this", cause) 58 | 59 | } catch (cause: ClassCastException) { 60 | // Caught if the connection cannot be cast to HttpUrlConnection. 61 | throw HttpException("Connection created for wrong protocol", cause) 62 | } 63 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/json/JsonObjectView.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.json 2 | 3 | /** 4 | * An unmodifiable view of a JSON object (aka a map or dictionary). 5 | */ 6 | internal interface JsonObjectView { 7 | 8 | /** 9 | * Retrieves the value within the object that's associated with the [key]. 10 | * 11 | * For [strings][String], [numbers][Number], and [booleans][Boolean], (JSON "primitives") this 12 | * will return the value itself. 13 | * 14 | * However, for objects and arrays, this will return a [JsonObjectView] or [JsonArrayView] 15 | * respectively, which provides a layer of abstraction over the underlying structure. 16 | * 17 | * @return the value associated with the [key], or `null` if there is no value, or if it is 18 | * explicitly set to `null`. 19 | * @see[getObject] 20 | * @see[getArray] 21 | * @see[getNumber] 22 | * @see[getString] 23 | * @see[getBoolean] 24 | */ 25 | operator fun get(key: String): Any? 26 | 27 | /** 28 | * Shorthand function for `get(key) as? JsonObjectView`. 29 | * @see[get] 30 | */ 31 | fun getObject(key: String): JsonObjectView? = get(key) as? JsonObjectView 32 | 33 | /** 34 | * Shorthand function for `get(key) as? JsonArrayView`. 35 | * @see[get] 36 | */ 37 | fun getArray(key: String): JsonArrayView? = get(key) as? JsonArrayView 38 | 39 | /** 40 | * Shorthand function for `get(key) as? Number`. 41 | * 42 | * If the value is actually a string, this will also attempt to parse it as a [Double], 43 | * returning `null` if it fails to do so. 44 | * 45 | * @see[get] 46 | */ 47 | fun getNumber(key: String): Number? = when (val value = get(key)) { 48 | // Return actual numbers as-is. 49 | is Number -> value 50 | 51 | // Attempt to parse the value as a number if it's a string, 52 | is String -> try { 53 | value.toDouble() 54 | } catch (cause: NumberFormatException) { 55 | null 56 | } 57 | 58 | // Otherwise, no dice. 59 | else -> null 60 | } 61 | 62 | /** 63 | * Shorthand function for `get(key) as? String`. 64 | * @see[get] 65 | */ 66 | fun getString(key: String): String? = when (val value = get(key)) { 67 | // Return actual strings as-is. 68 | is String -> value 69 | 70 | // Stringify numbers and booleans automatically. 71 | is Number, is Boolean -> value.toString() 72 | 73 | // Otherwise, no dice. 74 | else -> null 75 | } 76 | 77 | /** 78 | * Shorthand function for `get(key) as? Boolean`. 79 | * @see[get] 80 | */ 81 | fun getBoolean(key: String): Boolean? = get(key) as? Boolean 82 | 83 | override fun toString(): String 84 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/me/nullicorn/msmca/mock/MockHttpClient.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.mock 2 | 3 | import me.nullicorn.msmca.http.HttpClient 4 | import me.nullicorn.msmca.http.HttpException 5 | import me.nullicorn.msmca.http.Request 6 | import me.nullicorn.msmca.http.Response 7 | import me.nullicorn.msmca.minecraft.MinecraftXboxTokenRequest 8 | import me.nullicorn.msmca.xbox.XboxLiveTokenRequest 9 | import kotlin.reflect.KClass 10 | 11 | /** 12 | * A fake HTTP client whose responses can be manipulated based on the feature being tested. 13 | * @see[nextResponses] 14 | * @see[doThrowNext] 15 | */ 16 | class MockHttpClient : HttpClient { 17 | 18 | /** 19 | * Mock responses that the client should return when [send] receives a specific [Request] type. 20 | */ 21 | val nextResponses = mutableMapOf, Response>() 22 | 23 | /** 24 | * Whether the next call to [send] with raise an [HttpException], given the [Request] class. 25 | */ 26 | var doThrowNext = mutableMapOf, Boolean>() 27 | 28 | override fun send(request: Request): Response { 29 | if (doThrowNext[request::class] == true) 30 | throw HttpException("This is just a test") 31 | 32 | return nextResponses[request::class] 33 | ?: throw IllegalStateException("Missing mock response for ${request::class.simpleName}") 34 | } 35 | } 36 | 37 | private val XBOX_REQUEST_CLASS = XboxLiveTokenRequest::class 38 | private val MINECRAFT_REQUEST_CLASS = MinecraftXboxTokenRequest::class 39 | 40 | /** 41 | * The next response that should be returned by [MockHttpClient.send] when an [XboxLiveTokenRequest] 42 | * is sent. 43 | */ 44 | var MockHttpClient.nextXboxResponse: Response 45 | get() = nextResponses[XBOX_REQUEST_CLASS]!! 46 | set(value) { 47 | nextResponses[XBOX_REQUEST_CLASS] = value 48 | } 49 | 50 | /** 51 | * Whether the next call to [MockHttpClient.send] should throw an [HttpException] if the request is 52 | * a [XboxLiveTokenRequest]. 53 | */ 54 | var MockHttpClient.throwOnNextXboxRequest: Boolean 55 | get() = doThrowNext[XBOX_REQUEST_CLASS] ?: false 56 | set(value) { 57 | doThrowNext[XBOX_REQUEST_CLASS] = value 58 | } 59 | 60 | /** 61 | * The next response that should be returned by [MockHttpClient.send] when an 62 | * [MinecraftXboxTokenRequest] is sent. 63 | */ 64 | var MockHttpClient.nextMinecraftResponse: Response 65 | get() = nextResponses[MINECRAFT_REQUEST_CLASS]!! 66 | set(value) { 67 | nextResponses[MINECRAFT_REQUEST_CLASS] = value 68 | } 69 | 70 | /** 71 | * Whether the next call to [MockHttpClient.send] should throw an [HttpException] if the request is 72 | * a [MinecraftXboxTokenRequest]. 73 | */ 74 | var MockHttpClient.throwOnNextMinecraftRequest: Boolean 75 | get() = doThrowNext[MINECRAFT_REQUEST_CLASS] ?: false 76 | set(value) { 77 | doThrowNext[MINECRAFT_REQUEST_CLASS] = value 78 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /gradle/publish.gradle.kts: -------------------------------------------------------------------------------- 1 | apply(plugin = "signing") 2 | apply(plugin = "maven-publish") 3 | apply(plugin = "org.jetbrains.dokka") 4 | 5 | /////////////////////////////////////////////////////////////////////////// 6 | // Read Project's Configuration 7 | /////////////////////////////////////////////////////////////////////////// 8 | 9 | // Set in "../gradle.properties". 10 | val authorUrl = project.extra["author.url"] as String 11 | val projectName = project.extra["name"] as String 12 | 13 | // Both values should be set in "~/.gradle/gradle.properties". 14 | val ossrhUsername = project.extra["ossrhUsername"] as String 15 | val ossrhPassword = project.extra["ossrhPassword"] as String 16 | 17 | /////////////////////////////////////////////////////////////////////////// 18 | // Documentation HTML Jar 19 | /////////////////////////////////////////////////////////////////////////// 20 | 21 | // Include jar with the lib's KDoc HTML. 22 | val kdocJar by tasks.registering(Jar::class) { 23 | val htmlTask = tasks["dokkaHtml"] 24 | dependsOn(htmlTask) 25 | 26 | // Create the Jar from the generated HTML files. 27 | from(htmlTask) 28 | archiveClassifier.set("javadoc") 29 | } 30 | 31 | /////////////////////////////////////////////////////////////////////////// 32 | // Artifact Signing 33 | /////////////////////////////////////////////////////////////////////////// 34 | 35 | // Sign all of our artifacts for Nexus. 36 | configure { 37 | val publishing = extensions["publishing"] as PublishingExtension 38 | sign(publishing.publications) 39 | } 40 | 41 | /////////////////////////////////////////////////////////////////////////// 42 | // Publishing to OSSRH & Maven Central 43 | /////////////////////////////////////////////////////////////////////////// 44 | 45 | configure { 46 | // Add extra metadata for the JVM jar's pom.xml. 47 | publications.withType { 48 | artifact(kdocJar) 49 | 50 | pom { 51 | val projectUrl = "$authorUrl/$projectName" 52 | 53 | url.set("https://$projectUrl") 54 | name.set(projectName) 55 | description.set("Access a Minecraft account via Microsoft login") 56 | 57 | developers { 58 | developer { 59 | name.set("TheNullicorn") 60 | email.set("bennullicorn@gmail.com") 61 | } 62 | } 63 | 64 | licenses { 65 | license { 66 | name.set("MIT License") 67 | url.set("https://opensource.org/licenses/mit-license.php") 68 | } 69 | } 70 | 71 | scm { 72 | url.set("https://$projectUrl/tree/main") 73 | connection.set("scm:git:git://$projectUrl.git") 74 | developerConnection.set("scm:git:ssh://$projectUrl.git") 75 | } 76 | } 77 | } 78 | 79 | repositories { 80 | maven { 81 | name = "ossrh" 82 | 83 | val repoId = if (version.toString().endsWith("SNAPSHOT")) "snapshot" else "release" 84 | url = uri(project.extra["repo.$repoId.url"] as String) 85 | 86 | credentials { 87 | username = ossrhUsername 88 | password = ossrhPassword 89 | } 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/commonMain/resources/samples/Xbox.kt: -------------------------------------------------------------------------------- 1 | package samples 2 | 3 | import me.nullicorn.msmca.AuthException 4 | import me.nullicorn.msmca.xbox.XboxLiveAuth 5 | import me.nullicorn.msmca.xbox.XboxLiveAuthException 6 | 7 | object Xbox { 8 | /** 9 | * A sample demonstrating the combined usage of the classes in `me.nullicorn.msmca.xbox`. 10 | */ 11 | fun authViaMicrosoft() { 12 | /* 13 | * Make sure you have a valid access token for a Microsoft account. 14 | * ———————————————————————————————————————————————————————————————— 15 | * An easy way to get this is using the Microsoft Authentication Library (MSAL). It should 16 | * look like a long string of random letters and numbers. 17 | */ 18 | val microsoftAccessToken = "" 19 | 20 | /* 21 | * Create an Xbox Live authentication client. 22 | * —————————————————————————————————————————— 23 | * This class is pretty lightweight, but it's still good practice to only keep one instance 24 | * around if you'll be making a lot of requests. 25 | */ 26 | val xbox = XboxLiveAuth() 27 | 28 | 29 | /* 30 | * Exchange the Microsoft access token for an Xbox Live "user token". 31 | * —————————————————————————————————————————————————————————————————— 32 | * An XboxLiveAuthException may be thrown in a number of cases, such as... 33 | * - The token used is expired, or never was valid 34 | * - The Microsoft account isn't linked to Xbox Live 35 | * - The account's age is too young to access Xbox Live 36 | * - Xbox Live is not available in the account's country 37 | * - Xbox Live is experiencing an outage 38 | * It's recommended you display that reason to the user, preferably in a readable format. 39 | * You shouldn't need to log these exceptions. 40 | * 41 | * For more technical issues (typically related to the connection), an AuthException is 42 | * thrown. If you encounter one of these, it's recommended you log the exception and display 43 | * a more vague message to the user, since AuthExceptions cover a variety of issues. 44 | */ 45 | val xboxUserToken = try { 46 | xbox.getUserToken(microsoftAccessToken) 47 | 48 | } catch (cause: XboxLiveAuthException) { 49 | println("Failed to get user token; Xbox Live returned an error: ${cause.reason}") 50 | return 51 | 52 | } catch (cause: AuthException) { 53 | println("Failed to get user token; connection failed: ${cause.message}") 54 | return 55 | } 56 | 57 | /* 58 | * Similar to the previous method, but now we're exchanging the "user token" for a 59 | * "service token". 60 | * ——————————————————————————————————————————————————————————————————————————————— 61 | * This token is what you'll send to Minecraft's servers in order to get a Minecraft access 62 | * token, which you can finally use to access protected Minecraft services. 63 | * 64 | * For that side of things, see the samples in the "me.nullicorn.msmca.minecraft" package. 65 | */ 66 | val xboxServiceToken = try { 67 | xbox.getServiceToken(xboxUserToken.value) 68 | 69 | } catch (cause: XboxLiveAuthException) { 70 | println("Failed to get service token; Xbox Live returned an error: ${cause.reason}") 71 | return 72 | 73 | } catch (cause: AuthException) { 74 | println("Failed to get service token; connection failed: ${cause.message}") 75 | return 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /src/commonMain/resources/samples/Minecraft.kt: -------------------------------------------------------------------------------- 1 | package samples 2 | 3 | import me.nullicorn.msmca.AuthException 4 | import me.nullicorn.msmca.minecraft.MinecraftAuth 5 | import me.nullicorn.msmca.minecraft.MinecraftAuthException 6 | import me.nullicorn.msmca.xbox.XboxLiveAuthException 7 | 8 | object Minecraft { 9 | fun authViaMicrosoft() { 10 | /* 11 | * Make sure you have a valid access token for a Microsoft account. 12 | * ———————————————————————————————————————————————————————————————— 13 | * An easy way to get this is using the Microsoft Authentication Library (MSAL). It should 14 | * look like a long string of random letters and numbers. 15 | */ 16 | val microsoftAccessToken = "" 17 | 18 | /* 19 | * Create a Minecraft authentication client. 20 | * ————————————————————————————————————————— 21 | * This class is pretty lightweight, but it's still good practice to only keep one instance 22 | * around if you'll be making a lot of requests. 23 | */ 24 | val minecraft = MinecraftAuth() 25 | 26 | /* 27 | * Exchange the Microsoft access token for a Minecraft access token. 28 | * ————————————————————————————————————————————————————————————————— 29 | * An XboxLiveAuthException may be thrown in a number of cases, such as... 30 | * - The Microsoft access token used is expired, or never was valid 31 | * - The Microsoft account isn't linked to Xbox Live 32 | * - The account's age is too young to access Xbox Live 33 | * - Xbox Live is not available in the account's country 34 | * - Xbox Live is experiencing an outage 35 | * It's recommended you display that reason to the user, preferably in a readable format. 36 | * You shouldn't need to log these exceptions. 37 | * 38 | * For more technical issues (typically related to the connection), a MinecraftAuthException 39 | * or AuthException will be thrown. If you encounter one of these, it's recommended you log 40 | * the exception and display a more vague message to the user, since AuthExceptions cover a 41 | * variety of issues. 42 | */ 43 | val minecraftToken = try { 44 | minecraft.loginWithMicrosoft(microsoftAccessToken) 45 | 46 | } catch (cause: XboxLiveAuthException) { 47 | println("Failed to login to Minecraft; received an error from Xbox Live: ${cause.reason}") 48 | return 49 | 50 | } catch (cause: MinecraftAuthException) { 51 | println("Failed to login to Minecraft; received an error from Minecraft: ${cause.type}") 52 | return 53 | 54 | } catch (cause: AuthException) { 55 | println("Failed to login to Minecraft; connection failed: ${cause.message}") 56 | return 57 | } 58 | 59 | /* 60 | * Now you can access protected Minecraft services on behalf of the user, such as... 61 | * - Verifying their identity 62 | * - Changing their skin, cape, or username 63 | * - Log into online-mode servers with their account (especially for chat-bots) 64 | * 65 | * See https://wiki.vg/Mojang_API and https://wiki.vg/Protocol_Encryption for more info on 66 | * the endpoints available to you. 67 | * 68 | * If you're making those HTTP requests yourself, you'll need to include the token in the 69 | * "Authorization" header like so: 70 | * 71 | * Authorization: type value 72 | * 73 | * ...where 74 | * - "type" is the value of minecraftToken.type (usually "Bearer") 75 | * - "value" is the value of minecraftToken.value (in the form of a JWT) 76 | */ 77 | } 78 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/xbox/XboxLiveAuth.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.xbox 2 | 3 | import me.nullicorn.msmca.AuthException 4 | import me.nullicorn.msmca.http.BuiltInHttpClient 5 | import me.nullicorn.msmca.http.HttpClient 6 | import me.nullicorn.msmca.http.HttpException 7 | import me.nullicorn.msmca.json.JsonMappingException 8 | import me.nullicorn.msmca.xbox.XboxLiveError.Companion.xboxLiveError 9 | 10 | /** 11 | * Provides methods for authenticating with Xbox Live services. 12 | * 13 | * @param[httpClient] A custom HTTP client implementation, used to send requests to Xbox Live 14 | * services. If excluded, the library's builtin client will be used. 15 | * 16 | * @see wiki.vg - Microsoft Authentication 17 | * Scheme - heavily referenced when writing this class. 18 | */ 19 | class XboxLiveAuth(private val httpClient: HttpClient) { 20 | 21 | constructor() : this(BuiltInHttpClient) 22 | 23 | /** 24 | * Exchanges a Microsoft access token for an Xbox Live user token. 25 | * 26 | * @param[accessToken] A Microsoft access token, received from authentication. 27 | * @return credentials for the Xbox Live user. 28 | * @see[getServiceToken] 29 | * 30 | * @throws[AuthException] if the connection to the Xbox Live service fails. 31 | * @throws[AuthException] if Xbox Live returns a malformed or incomplete response body. 32 | * @throws[XboxLiveAuthException] if Xbox Live returns a status code that isn't between `200` 33 | * and `299`, both included. 34 | */ 35 | fun getUserToken(accessToken: String) = getCredentials( 36 | request = XboxLiveTokenRequest.user(accessToken) 37 | ) 38 | 39 | /** 40 | * Exchanges an Xbox Live user token for a service token. 41 | * 42 | * @param[userToken] An Xbox Live user token. 43 | * @return a service token for the token owner. 44 | * @see[getUserToken] 45 | * 46 | * @throws[AuthException] if the connection to the Xbox Live service fails. 47 | * @throws[AuthException] if Xbox Live returns a malformed or incomplete response body. 48 | * @throws[XboxLiveAuthException] if Xbox Live returns a status code that isn't between `200` 49 | * and `299`, both included. 50 | */ 51 | fun getServiceToken(userToken: String) = getCredentials( 52 | request = XboxLiveTokenRequest.xsts(userToken) 53 | ) 54 | 55 | /** 56 | * Internal logic shared between [getUserToken] and [getServiceToken], which use the exact 57 | * format for both the request and response. 58 | * 59 | * @throws[AuthException] if the connection to the Xbox Live service fails. 60 | * @throws[AuthException] if Xbox Live returns a malformed or incomplete response body. 61 | * @throws[XboxLiveAuthException] if Xbox Live returns a status code that isn't between `200` 62 | * and `299`, both included. 63 | */ 64 | private fun getCredentials(request: XboxLiveTokenRequest): XboxLiveToken { 65 | val response = try { 66 | httpClient.send(request) 67 | } catch (cause: HttpException) { 68 | // Caught if the request itself fails. 69 | throw AuthException("Failed to request user credentials", cause) 70 | } 71 | 72 | // If the response code isn't 2xx, attempt to read the error's details. 73 | val error = response.xboxLiveError 74 | if (error != null) throw XboxLiveAuthException(error) 75 | 76 | // Attempt to parse the response. 77 | val respJson = try { 78 | response.asJsonObject() 79 | } catch (cause: JsonMappingException) { 80 | // Caught if the response cannot be parsed as JSON. 81 | throw AuthException("Malformed response from Xbox Live service", cause) 82 | } 83 | 84 | return try { 85 | XboxLiveToken(respJson) 86 | } catch (cause: IllegalArgumentException) { 87 | throw AuthException("Incomplete response from Xbox Live service", cause) 88 | } 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/http/HttpUrlConnection.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.http 2 | 3 | import me.nullicorn.msmca.json.JsonMapper 4 | import java.io.IOException 5 | import java.io.InputStreamReader 6 | import java.net.HttpURLConnection 7 | 8 | /* 9 | * Extensions for java.net.HttpURLConnection, allowing it to neatly be integrated into our internal 10 | * HTTP API. 11 | */ 12 | 13 | /** 14 | * Configures the connection to use the same [method][Request.method], [headers][Request.headers], 15 | * and [body][Request.body], if applicable, as the supplied request. 16 | * 17 | * @param[options] The request whose options should be applied to the connection. 18 | */ 19 | internal fun HttpURLConnection.configure(options: Request) { 20 | 21 | // Set the request's method (e.g. GET or POST). 22 | requestMethod = options.method.uppercase() 23 | 24 | // Set each of the request's headers, if present. 25 | for ((header, value) in options.headers) { 26 | setRequestProperty(header, value) 27 | } 28 | 29 | // Disable user interaction, since there's no "user" in this context. 30 | allowUserInteraction = false 31 | 32 | // Ignore redirect responses (status codes 3xx). 33 | instanceFollowRedirects = false 34 | 35 | connectTimeout = 4000 36 | readTimeout = 2000 37 | 38 | // Format the request's body as a JSON string, if present. 39 | val body = options.body 40 | if (body != null) { 41 | doOutput = true 42 | outputStream.writer().use { it.write(JsonMapper.stringify(body)) } 43 | } 44 | } 45 | 46 | /** 47 | * Opens the connection, then deserializes the response as a [Response] object. 48 | * 49 | * @throws[HttpException] if the connection fails, or if the server sends a malformed response. 50 | */ 51 | internal val HttpURLConnection.response: Response 52 | get() { 53 | // Connect, catching any errors that occur. 54 | val error = try { 55 | connect() 56 | } catch (cause: IOException) { 57 | // connect() throws just for HTTP status codes >= 400, even if the response is valid. 58 | // The only errors we care about are connection ones, which is why we check for a -1 59 | // status code below. 60 | cause 61 | } 62 | 63 | // If the connection didn't turn up a status code, then rethrow whatever connect() threw. 64 | if (responseCode == -1) { 65 | throw HttpException("HTTP request failed", 66 | cause = error as? Throwable) 67 | } 68 | 69 | // Parse the response headers & body, then wrap them in our Response class. 70 | return try { 71 | Response(responseCode, responseHeaders, responseBody) 72 | } catch (cause: IOException) { 73 | throw HttpException("HTTP response could not be parsed", cause) 74 | } 75 | } 76 | 77 | /** 78 | * The first value sent for each header in the HTTP response. 79 | * 80 | * Should be used instead of [headerFields][HttpURLConnection.getHeaderFields], which returns *all* 81 | * values sent for each header. 82 | */ 83 | private val HttpURLConnection.responseHeaders: Headers 84 | get() = headerFields.let { original -> 85 | buildMap { 86 | for ((name, values) in original.entries) put(name ?: "", values.first()) 87 | } 88 | } 89 | 90 | /** 91 | * Reads the contents of the response body as plaintext. 92 | * @throws IOException if the body cannot not be read. 93 | */ 94 | private val HttpURLConnection.responseBody: String 95 | get() { 96 | // If the server sent nothing, return an empty string by default. 97 | // 98 | // errorStream returns null in this case, and inputStream may throw an exception, so this 99 | // check has to come first. 100 | if (contentLength == 0) return "" 101 | 102 | // Get a reader of the body, regardless of whether it's an error or not. 103 | val bodyReader = (errorStream ?: inputStream)?.reader() 104 | // Read all the body's text, then close the stream afterwards (via use()). 105 | return bodyReader?.use(InputStreamReader::readText) ?: "" 106 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minecraft Login using Microsoft 2 | 3 | Simplifies the process of logging into [Minecraft services](https://wiki.vg/Mojang_API) on behalf of 4 | a Microsoft user. In order to use this library, you will need to create an app that interfaces with 5 | the [Microsoft Identity Platform][ms-openid]. The easiest way is using an implementation of the 6 | Microsoft Authentication Library ([MSAL][msal-overview]). 7 | 8 | Once your app is set up, request an **access token** from the user (that's what you'll need to use this library). Be sure to ask for the `XboxLive.signin` scope! It's required for 9 | logging in to Minecraft. 10 | 11 | # 🚨 Disclaimer 🚨 12 | 13 | Firstly, **Minecraft access tokens can be dangerous!** Tokens grant an application **full permissions** to a user's account, meaning they can... 14 | - connect to online-mode servers on the user's behalf 15 | - change profile info... 16 | - username 17 | - capes 18 | - skins 19 | 20 | I **strongly** recommend you provide a similar disclaimer to users logging into your own app, both for their security and to raise awareness of the dangers of Minecraft authentication with third-party apps. 21 | 22 | Secondly, **this is NOT a Minecraft API library**. Its only function is exchanging Microsoft access tokens for Minecraft ones, which can be used to perform actions on behalf of a Minecraft account. To interface with the rest of Minecraft's online services you'll need a separate library of your choice. 23 | 24 | ## Installation 25 | 26 | ### Gradle 27 | 28 | #### Kotlin DSL 29 | 30 | ```kotlin 31 | dependencies { 32 | implementation("me.nullicorn:ms-to-mca:0.0.1") 33 | // ...your other dependencies... 34 | } 35 | ``` 36 | 37 | #### Groovy DSL 38 | 39 | ```groovy 40 | dependencies { 41 | implementation 'me.nullicorn:ms-to-mca:0.0.1' 42 | // ...your other dependencies... 43 | } 44 | ``` 45 | 46 | ### Maven 47 | 48 | ```xml 49 | 50 | 51 | me.nullicorn 52 | ms-to-mca 53 | 0.0.1 54 | 55 | 56 | 57 | ``` 58 | 59 | ## Usage 60 | 61 | ### Quick Login 62 | 63 | This process takes you straight from Microsoft --> Minecraft token, without dealing with Xbox by 64 | hand. 65 | 66 | ```kotlin 67 | // Authentication with Minecraft is done via the MinecraftAuth class. 68 | val minecraft = MinecraftAuth() 69 | 70 | // Exchanges your Microsoft access token for a Minecraft access token. 71 | val token: MinecraftToken = minecraft.login("") 72 | ``` 73 | 74 | A `MinecraftToken` has four attributes: 75 | 76 | - `type` (String) The authentication scheme to use the token with. 77 | - "scheme" refers to the word before the token when used in an `Authorization` header. 78 | - e.g. if the scheme is `Bearer`, the header's value would look like `Bearer `

79 | - `value` (String) The actual value of the token. 80 | - This *should* be a valid JWT, but may not be if Minecraft ever decides to change the type of 81 | token it uses.

82 | - `user` (String) The UUID of the Minecraft account that the token is for. 83 | - This is **not** the same as the UUID of the player that belongs to the account. 84 | - This UUID *does* have hyphens, unlike UUIDs in other parts of Minecraft's APIs.

85 | - `duration` (Int) The number of seconds that the token will last for after its creation. 86 | 87 | ### Login via Xbox Live 88 | 89 | This process is slightly more involved, but lets you access the two intermediary tokens for Xbox 90 | Live, if you need them for whatever reason. 91 | 92 | If you already have an Xbox Live token for some reason, this also allows you to use it directly for 93 | login. 94 | 95 | ```kotlin 96 | // Xbox Live services are interacted with using the XboxLiveAuth class. 97 | val xbox = XboxLiveAuth() 98 | 99 | // The first step is to exchange your Microsoft access token for an Xbox Live "user" token. 100 | val userToken = xbox.getUserToken("") 101 | 102 | // The next step is to exchange your user token for an Xbox Live "service" token. This can also be 103 | // referred to as an "XSTS token". 104 | val serviceToken = xbox.getServiceToken(userToken.value) 105 | 106 | // Now, you can get the same MinecraftToken object as in the "Quick Login". 107 | val minecraft = MinecraftAuth() 108 | val minecraftToken = minecraft.login(serviceToken) 109 | ``` 110 | 111 | [ms-openid]: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc 112 | 113 | [msal-overview]: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-overview 114 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/minecraft/MinecraftAuth.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.minecraft 2 | 3 | import me.nullicorn.msmca.AuthException 4 | import me.nullicorn.msmca.http.BuiltInHttpClient 5 | import me.nullicorn.msmca.http.HttpClient 6 | import me.nullicorn.msmca.http.HttpException 7 | import me.nullicorn.msmca.json.JsonMappingException 8 | import me.nullicorn.msmca.xbox.XboxLiveAuth 9 | import me.nullicorn.msmca.xbox.XboxLiveAuthException 10 | import me.nullicorn.msmca.xbox.XboxLiveToken 11 | 12 | /** 13 | * Provides methods for authenticating with Minecraft-related services. 14 | * 15 | * @param[httpClient] An HTTP client used to send requests to Minecraft's authentication service. 16 | * @param[xboxClient] An Xbox Live authentication client, used by the [loginWithXbox] method. 17 | */ 18 | class MinecraftAuth(private val httpClient: HttpClient, private val xboxClient: XboxLiveAuth) { 19 | 20 | /** 21 | * Creates a client that communicates with Minecraft's authentication service via the provided 22 | * [httpClient]. 23 | * 24 | * If the [loginWithXbox] method is used, requests to Xbox Services will be sent using that 25 | * [httpClient] as well. 26 | */ 27 | constructor(httpClient: HttpClient) : this(httpClient, XboxLiveAuth(httpClient)) 28 | 29 | /** 30 | * Creates a client that communicates with Minecraft's authentication service via a builtin 31 | * [HttpClient]. 32 | */ 33 | constructor() : this(BuiltInHttpClient) 34 | 35 | /** 36 | * Exchanges an Xbox Live [service token][XboxLiveAuth.getServiceToken] for a Minecraft access 37 | * token. 38 | * 39 | * @param[credentials] An active Xbox Live service token. 40 | * @return a token used to authenticate with protected Minecraft services. 41 | * @see[loginWithMicrosoft] 42 | * 43 | * @throws[AuthException] if the connection to Minecraft's authentication service fails. 44 | * @throws[AuthException] if Minecraft's authentication service returns a malformed or 45 | * incomplete response. 46 | * @throws[MinecraftAuthException] if Minecraft's authentication service returns a status code 47 | * that isn't between `200` and `299`, both included. 48 | */ 49 | fun loginWithXbox(credentials: XboxLiveToken): MinecraftToken { 50 | val request = MinecraftXboxTokenRequest(credentials) 51 | 52 | val response = try { 53 | httpClient.send(request) 54 | } catch (cause: HttpException) { 55 | // Caught if the request itself fails. 56 | throw AuthException("Failed to request user credentials", cause) 57 | } 58 | 59 | // If the response code isn't 2xx, attempt to read the error's details. 60 | if (!response.isSuccess) { 61 | val error = try { 62 | response.asJsonObject().getString("errorType") 63 | } catch (cause: JsonMappingException) { 64 | null 65 | } 66 | 67 | throw MinecraftAuthException(error) 68 | } 69 | 70 | // Attempt to read the token from the response. 71 | return try { 72 | MinecraftToken(response.asJsonObject()) 73 | } catch (cause: JsonMappingException) { 74 | // Caught if the response fails to parse as JSON. 75 | throw AuthException("Malformed response from Minecraft servers", cause) 76 | } catch (cause: IllegalArgumentException) { 77 | // Caught if the response parses, but doesn't contain required fields. 78 | throw AuthException("Minecraft did not send a valid token", cause) 79 | } 80 | } 81 | 82 | /** 83 | * Simplifies the login process by logging into Xbox Live internally, then exchanging that token 84 | * for a Minecraft access token. 85 | * 86 | * @param[microsoftToken] A valid access token for a Microsoft account. 87 | * 88 | * @throws[AuthException] if the connection to Minecraft's authentication service fails. 89 | * @throws[AuthException] if Minecraft's authentication service returns a malformed or 90 | * incomplete response. 91 | * @throws[AuthException] if the connection to the Xbox Live service fails. 92 | * @throws[AuthException] if Xbox Live returns a malformed or incomplete response. 93 | * @throws[XboxLiveAuthException] if Xbox Live returns a status code that isn't between `200` 94 | * and `299`, both included. 95 | * @throws[MinecraftAuthException] if Minecraft's authentication service returns a status code 96 | * that isn't between `200` and `299`, both included. 97 | * 98 | */ 99 | fun loginWithMicrosoft(microsoftToken: String): MinecraftToken { 100 | 101 | val userToken = try { 102 | xboxClient.getUserToken(microsoftToken) 103 | } catch (cause: AuthException) { 104 | throw AuthException("Failed to fetch a user token", cause) 105 | } 106 | 107 | val xstsToken = try { 108 | xboxClient.getServiceToken(userToken.value) 109 | } catch (cause: AuthException) { 110 | throw AuthException("Failed to fetch a service token", cause) 111 | } 112 | 113 | return loginWithXbox(xstsToken) 114 | } 115 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/me/nullicorn/msmca/mock/MockHttpResponses.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.mock 2 | 3 | import me.nullicorn.msmca.http.Response 4 | import me.nullicorn.msmca.xbox.XboxLiveError 5 | import me.nullicorn.msmca.xbox.XboxLiveToken 6 | 7 | /** 8 | * Sample HTTP responses used to test how the library responds to various inputs from Microsoft and 9 | * Minecraft's services. 10 | * 11 | * This is meant to be used in conjunction with [MockHttpClient]. 12 | */ 13 | object MockResponses { 14 | 15 | /** 16 | * Sample responses from Xbox Live services specifically. 17 | */ 18 | object Xbox { 19 | 20 | /** 21 | * Creates a valid response that shouldn't raise any errors from the library, but allows it 22 | * to be modified to test how the library response to various edge-cases in the response's 23 | * structure. 24 | * 25 | * @param[modifier] A function that tweaks any combination of the response's status, 26 | * headers, and body. 27 | */ 28 | fun validBut(modifier: (MutableResponse) -> Unit) = 29 | MutableResponse().apply(modifier).toResponse() 30 | 31 | /** 32 | * Creates a valid response that the library should interpret as an [XboxLiveToken] equal to 33 | * the supplied [token]. 34 | * 35 | * @param[token] The token to mimic the response for. 36 | */ 37 | fun validForToken(token: XboxLiveToken) = 38 | MutableResponse(token = token.value, userHash = token.user).toResponse() 39 | 40 | /** 41 | * Modifies an otherwise valid response to include an `XErr` header, which the library 42 | * should raise an error for, interpreting the `XErr` as an [XboxLiveError] value. 43 | * 44 | * @param[error] The value to put in the `XErr` field. 45 | */ 46 | fun withErrorCodeInHeader(error: Long?) = validBut { response -> 47 | response.status = 403 48 | response.headers["XErr"] = "$error" 49 | } 50 | 51 | /** 52 | * Modifies an otherwise valid response to include an `XErr` field in the JSON body, which 53 | * the library should raise an error for, interpreting the `XErr` as an [XboxLiveError] 54 | * value. 55 | * 56 | * @param[error] The value to put in the `XErr` field. 57 | */ 58 | fun withErrorCodeInBody(error: Long?) = validBut { response -> 59 | response.status = 403 60 | response.body = """ 61 | { 62 | "XErr": $error 63 | } 64 | """.trimIndent() 65 | } 66 | 67 | /** 68 | * A response that is otherwise valid, but is missing the field with the necessary access 69 | * token. 70 | */ 71 | fun withoutTokenInBody() = validBut { response -> 72 | response.body = """ 73 | { 74 | "DisplayClaims": { 75 | "xui": [ 76 | { 77 | "uhs": "0" 78 | } 79 | ] 80 | } 81 | } 82 | """.trimIndent() 83 | } 84 | 85 | /** 86 | * A series of responses that are otherwise valid, but are missing the field with the user's 87 | * hash in the response body. 88 | */ 89 | fun manyWithoutUserHashInBody(): List = arrayOf( 90 | """ 91 | { 92 | "Token": "${MockTokens.SIMPLE}" 93 | } 94 | """, 95 | """ 96 | { 97 | "Token": "${MockTokens.SIMPLE}", 98 | "DisplayClaims": null 99 | } 100 | """, 101 | """ 102 | { 103 | "Token": "${MockTokens.SIMPLE}", 104 | "DisplayClaims": {} 105 | } 106 | """, 107 | """ 108 | { 109 | "Token": "${MockTokens.SIMPLE}", 110 | "DisplayClaims": { 111 | "NoXuiInSight": true 112 | } 113 | } 114 | """, 115 | """ 116 | { 117 | "Token": "${MockTokens.SIMPLE}", 118 | "DisplayClaims": { 119 | "xui": null 120 | } 121 | } 122 | """, 123 | """ 124 | { 125 | "Token": "${MockTokens.SIMPLE}", 126 | "DisplayClaims": { 127 | "xui": [] 128 | } 129 | } 130 | """, 131 | """ 132 | { 133 | "Token": "${MockTokens.SIMPLE}", 134 | "DisplayClaims": { 135 | "xui": [ {} ] 136 | } 137 | } 138 | """, 139 | """ 140 | { 141 | "Token": "${MockTokens.SIMPLE}", 142 | "DisplayClaims": { 143 | "xui": [ 144 | { 145 | "NoHashHere": true 146 | } 147 | ] 148 | } 149 | } 150 | """, 151 | ).map { body -> 152 | validBut { it.body = body.trimIndent() } 153 | } 154 | } 155 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/me/nullicorn/msmca/xbox/XboxLiveError.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.xbox 2 | 3 | import me.nullicorn.msmca.http.Response 4 | import me.nullicorn.msmca.json.JsonMappingException 5 | 6 | /** 7 | * Known error codes that can be returned from Xbox Live services. 8 | * 9 | * If one is received, it's recommended to display it to users in a friendly format to help them 10 | * troubleshoot the issue. It may often be the result of user error, or possibly a server issue that 11 | * is out of the user's control. 12 | */ 13 | enum class XboxLiveError { 14 | /** 15 | * Indicates that Xbox Live sent an unrecognized error code. 16 | */ 17 | UNKNOWN, 18 | 19 | // User errors. 20 | 21 | /** 22 | * Indicates that the user's Microsoft account has no corresponding Xbox Live account. 23 | */ 24 | XBOX_NOT_LINKED, 25 | 26 | /** 27 | * Indicates that the account is a child, and must be added to a Microsoft family to 28 | * authenticate. 29 | */ 30 | AGE_TOO_YOUNG, 31 | 32 | /** 33 | * Indicates that the user needs to verify their age at xbox.com to authenticate. 34 | */ 35 | AGE_NOT_VERIFIED, 36 | 37 | /** 38 | * Indicates that Xbox Live is banned in the user's country or region. 39 | */ 40 | REGION_NOT_ALLOWED, 41 | 42 | // Technical errors. 43 | 44 | /** 45 | * Indicates that Xbox Live's authentication service is experiencing an outage. 46 | */ 47 | OUTAGE, 48 | 49 | /** 50 | * Indicates that the client was denied access to Xbox Live's production environment. This 51 | * should not happen. 52 | */ 53 | SANDBOX_NOT_ALLOWED, 54 | 55 | /** 56 | * Indicates that an invalid Microsoft access token was used to authenticate. 57 | * 58 | * A new one can be requested using the Microsoft Authentication Library (MSAL). 59 | */ 60 | MICROSOFT_TOKEN_INVALID, 61 | 62 | /** 63 | * Indicates that an expired Microsoft access token was used to authenticate. 64 | * 65 | * A new one can be requested using the Microsoft Authentication Library (MSAL). 66 | */ 67 | MICROSOFT_TOKEN_EXPIRED, 68 | 69 | /** 70 | * Indicates that an expired user token was used to authenticate. 71 | * 72 | * A new one can be requested using 73 | * [XboxLiveAuth.getUserToken][XboxLiveAuth.getUserToken]. 74 | */ 75 | USER_TOKEN_EXPIRED, 76 | 77 | /** 78 | * Indicates that an invalid user token was used to authenticate. 79 | * 80 | * A valid one can be requested using 81 | * [XboxLiveAuth.getUserToken][XboxLiveAuth.getUserToken]. 82 | */ 83 | USER_TOKEN_INVALID, 84 | 85 | /** 86 | * Indicates that an expired service token was used to authenticate. 87 | * 88 | * A new one can be requested using 89 | * [XboxLiveAuth.getServiceToken][XboxLiveAuth.getServiceToken]. 90 | */ 91 | SERVICE_TOKEN_EXPIRED, 92 | 93 | /** 94 | * Indicates that an invalid service token was used to authenticate. 95 | * 96 | * A valid one can be requested using 97 | * [XboxLiveAuth.getServiceToken][XboxLiveAuth.getServiceToken]. 98 | */ 99 | SERVICE_TOKEN_INVALID; 100 | 101 | internal companion object { 102 | internal val errorsByCode: Map = mapOf( 103 | null to UNKNOWN, 104 | 105 | // User errors. 106 | 0x8015DC09 to XBOX_NOT_LINKED, 107 | 0x8015DC0B to REGION_NOT_ALLOWED, 108 | 0x8015DC0E to AGE_TOO_YOUNG, 109 | 0x8015DC0C to AGE_NOT_VERIFIED, 110 | 0x8015DC0D to AGE_NOT_VERIFIED, 111 | 112 | // Technical errors. 113 | 0x8015DC12 to SANDBOX_NOT_ALLOWED, 114 | 0x8015DC22 to USER_TOKEN_EXPIRED, 115 | 0x8015DC26 to USER_TOKEN_INVALID, 116 | 0x8015DC1F to SERVICE_TOKEN_EXPIRED, 117 | 0x8015DC27 to SERVICE_TOKEN_INVALID, 118 | 0x8015DC31 to OUTAGE, 119 | 0x8015DC32 to OUTAGE, 120 | ) 121 | 122 | /** 123 | * Converts an Xbox Live error code to one of the known enum values. 124 | * 125 | * @param[xErr] the value of the `XErr` field from an Xbox Live API response. 126 | * @return the corresponding enum value for the error, or [UNKNOWN] if the error code cannot 127 | * be interpreted. 128 | */ 129 | internal fun fromXErr(xErr: Any?): XboxLiveError { 130 | val numericCode: Long? = when (xErr) { 131 | // Return numeric codes as-is. 132 | is Number -> xErr.toLong() 133 | 134 | // Parse string error codes as numbers. 135 | is String -> try { 136 | xErr.toLong() 137 | } catch (cause: NumberFormatException) { 138 | null 139 | } 140 | 141 | // Anything else should be considered invalid, and thus null. 142 | else -> null 143 | } 144 | 145 | return errorsByCode[numericCode] ?: UNKNOWN 146 | } 147 | 148 | /** 149 | * Interprets the response as an Xbox Live error, if possible. 150 | * 151 | * If the response doesn't represent an error, `null` is returned. 152 | */ 153 | internal val Response.xboxLiveError: XboxLiveError? 154 | get() { 155 | if (isSuccess) return null 156 | 157 | // XErr can either be a header or body field. 158 | val xErr = headers["XErr"] ?: try { 159 | this.asJsonObject()["XErr"] 160 | } catch (cause: JsonMappingException) { 161 | null 162 | } 163 | 164 | return when { 165 | // Xbox returned a readable error message. 166 | xErr != null -> fromXErr(xErr) 167 | // 400 indicates that the token is malformed. 168 | status == 400 -> MICROSOFT_TOKEN_INVALID 169 | // 401 indicates that the token is valid, but expired. 170 | status == 401 -> MICROSOFT_TOKEN_EXPIRED 171 | // Fall-back reason. 172 | else -> UNKNOWN 173 | } 174 | } 175 | } 176 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MSYS* | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/me/nullicorn/msmca/xbox/XboxLiveAuthTests.kt: -------------------------------------------------------------------------------- 1 | package me.nullicorn.msmca.xbox 2 | 3 | import me.nullicorn.msmca.AuthException 4 | import me.nullicorn.msmca.mock.* 5 | import me.nullicorn.msmca.util.* 6 | import kotlin.js.JsName 7 | import kotlin.math.PI 8 | import kotlin.test.* 9 | 10 | class XboxLiveAuthTests { 11 | 12 | private lateinit var http: MockHttpClient 13 | private lateinit var xbox: XboxLiveAuth 14 | 15 | @BeforeTest 16 | fun setUp() { 17 | http = MockHttpClient() 18 | xbox = XboxLiveAuth(http) 19 | } 20 | 21 | @Test 22 | @JsName(ONE) 23 | fun `should not throw when public constructor is called`() { 24 | assertSucceeds { XboxLiveAuth() } 25 | } 26 | 27 | @Test 28 | @JsName(TWO) 29 | fun `should throw AuthException if the request fails`() { 30 | http.throwOnNextXboxRequest = true 31 | 32 | assertFailsWith(AuthException::class) { 33 | xbox.getUserToken(MockTokens.SIMPLE) 34 | } 35 | 36 | assertFailsWith(AuthException::class) { 37 | xbox.getServiceToken(MockTokens.SIMPLE) 38 | } 39 | } 40 | 41 | @Test 42 | @JsName(THREE) 43 | fun `should succeed if the response's status code is between 200 and 299`() { 44 | for (status in 200..299) { 45 | // Respond to all requests with a fake response. 46 | // Its status code is whichever one we're at in the loop. 47 | http.nextResponses[XboxLiveTokenRequest::class] = 48 | MockResponses.Xbox.validBut { response -> 49 | response.status = status 50 | } 51 | 52 | assertSucceeds("get user token when status=$status") { 53 | xbox.getUserToken(MockTokens.SIMPLE) 54 | } 55 | 56 | assertSucceeds("get service token when status=$status") { 57 | xbox.getServiceToken(MockTokens.SIMPLE) 58 | } 59 | } 60 | } 61 | 62 | @Test 63 | @JsName(FOUR) 64 | fun `should throw XboxLiveAuthException if the response's status code is not between 200 and 299`() { 65 | for (status in 0..2000 step 3) { 66 | if (status in 200..299) continue 67 | 68 | http.nextXboxResponse = MockResponses.Xbox.validBut { response -> 69 | response.status = status 70 | } 71 | 72 | assertFailsWith { 73 | xbox.getUserToken(MockTokens.SIMPLE) 74 | } 75 | 76 | assertFailsWith { 77 | xbox.getServiceToken(MockTokens.SIMPLE) 78 | } 79 | } 80 | } 81 | 82 | @Test 83 | @JsName(FIVE) 84 | fun `should throw AuthException if the response's body is not valid JSON`() { 85 | for (status in 200..299) { 86 | // Remove all quotes from the response JSON, thus invalidating it. 87 | http.nextXboxResponse = MockResponses.Xbox.validBut { response -> 88 | response.body = response.body.replace("\"", "") 89 | } 90 | 91 | assertFailsWith { 92 | xbox.getUserToken(MockTokens.SIMPLE) 93 | } 94 | 95 | assertFailsWith { 96 | xbox.getServiceToken(MockTokens.SIMPLE) 97 | } 98 | } 99 | } 100 | 101 | @Test 102 | @JsName(SIX) 103 | fun `should XboxLiveAuthException have the correct XboxLiveError if the response header has one`() { 104 | for ((numericCode, error) in XboxLiveError.errorsByCode) { 105 | http.nextXboxResponse = MockResponses.Xbox.withErrorCodeInHeader(numericCode) 106 | 107 | val userException = assertFailsWith { 108 | xbox.getUserToken(MockTokens.SIMPLE) 109 | } 110 | 111 | val serviceException = assertFailsWith { 112 | xbox.getServiceToken(MockTokens.SIMPLE) 113 | } 114 | 115 | assertEquals(error, userException.reason) 116 | assertEquals(error, serviceException.reason) 117 | } 118 | } 119 | 120 | @Test 121 | @JsName(SEVEN) 122 | fun `should XboxLiveAuthException have the correct XboxLiveError if the response body has one`() { 123 | for ((numericCode, error) in XboxLiveError.errorsByCode) { 124 | http.nextXboxResponse = MockResponses.Xbox.withErrorCodeInBody(numericCode) 125 | 126 | val userException = assertFailsWith { 127 | xbox.getUserToken(MockTokens.SIMPLE) 128 | } 129 | 130 | val serviceException = assertFailsWith { 131 | xbox.getServiceToken(MockTokens.SIMPLE) 132 | } 133 | 134 | assertEquals(error, userException.reason) 135 | assertEquals(error, serviceException.reason) 136 | } 137 | } 138 | 139 | @Test 140 | @JsName(EIGHT) 141 | fun `should XboxLiveAuthException's reason be MICROSOFT_TOKEN_INVALID if status is 401`() { 142 | http.nextXboxResponse = MockResponses.Xbox.validBut { response -> 143 | response.status = 400 144 | } 145 | 146 | val userException = assertFailsWith { 147 | xbox.getUserToken(MockTokens.SIMPLE) 148 | } 149 | 150 | val serviceException = assertFailsWith { 151 | xbox.getServiceToken(MockTokens.SIMPLE) 152 | } 153 | 154 | assertEquals(XboxLiveError.MICROSOFT_TOKEN_INVALID, userException.reason) 155 | assertEquals(XboxLiveError.MICROSOFT_TOKEN_INVALID, serviceException.reason) 156 | } 157 | 158 | @Test 159 | @JsName(NINE) 160 | fun `should XboxLiveAuthException's reason be MICROSOFT_TOKEN_EXPIRED if status is 401`() { 161 | http.nextXboxResponse = MockResponses.Xbox.validBut { response -> 162 | response.status = 401 163 | } 164 | 165 | val userException = assertFailsWith { 166 | xbox.getUserToken(MockTokens.SIMPLE) 167 | } 168 | 169 | val serviceException = assertFailsWith { 170 | xbox.getServiceToken(MockTokens.SIMPLE) 171 | } 172 | 173 | assertEquals(XboxLiveError.MICROSOFT_TOKEN_EXPIRED, userException.reason) 174 | assertEquals(XboxLiveError.MICROSOFT_TOKEN_EXPIRED, serviceException.reason) 175 | } 176 | 177 | @Test 178 | @JsName(TEN) 179 | fun `should throw AuthException if response doesn't include a token`() { 180 | http.nextXboxResponse = MockResponses.Xbox.withoutTokenInBody() 181 | 182 | assertFailsWith { 183 | xbox.getUserToken(MockTokens.SIMPLE) 184 | } 185 | 186 | assertFailsWith { 187 | xbox.getServiceToken(MockTokens.SIMPLE) 188 | } 189 | } 190 | 191 | @Test 192 | @JsName(ELEVEN) 193 | fun `should throw AuthException if response doesn't include a user hash`() { 194 | for (response in MockResponses.Xbox.manyWithoutUserHashInBody()) { 195 | http.nextXboxResponse = response 196 | 197 | assertFailsWith { 198 | xbox.getUserToken(MockTokens.SIMPLE) 199 | } 200 | 201 | assertFailsWith { 202 | xbox.getServiceToken(MockTokens.SIMPLE) 203 | } 204 | } 205 | } 206 | 207 | @Test 208 | @JsName(TWELVE) 209 | fun `should return an XboxLiveToken with the same value and hash from the response's body`() { 210 | // User tokens 211 | val userToken = XboxLiveToken( 212 | value = MockTokens.SIMPLE, 213 | user = (PI * 100_000).toInt().toString(), 214 | ) 215 | http.nextXboxResponse = MockResponses.Xbox.validForToken(userToken) 216 | 217 | val actualUserToken = xbox.getUserToken("my.access.token") 218 | assertEquals(userToken.value, actualUserToken.value) 219 | assertEquals(userToken.user, actualUserToken.user) 220 | assertEquals(userToken, actualUserToken) 221 | 222 | // Service tokens. 223 | 224 | val serviceToken = XboxLiveToken( 225 | value = userToken.value.reversed(), 226 | user = userToken.user.reversed(), 227 | ) 228 | http.nextXboxResponse = MockResponses.Xbox.validForToken(serviceToken) 229 | 230 | val actualServiceToken = xbox.getServiceToken("your.access.token") 231 | assertEquals(serviceToken.value, actualServiceToken.value) 232 | assertEquals(serviceToken.user, actualServiceToken.user) 233 | assertEquals(serviceToken, actualServiceToken) 234 | } 235 | } -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 583 | --------------------------------------------------------------------------------