├── .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 |
4 |
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 |