770 lines
26 KiB
Diff
770 lines
26 KiB
Diff
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
|
Date: Mon, 14 Sep 2026 14:43:17 +0200
|
|
Subject: [PATCH] Sign in to Microsoft in the player's own browser
|
|
|
|
The launcher opened Microsoft's sign-in page in a webview of its own,
|
|
which is the one place a player cannot use the tools they use everywhere
|
|
else: no password manager, no autofill, no passkeys, and no way to tell
|
|
by looking that the page is really Microsoft's.
|
|
|
|
The browser gets all of it. What it cannot do is hand a code back: this
|
|
client id is Minecraft's own, and no loopback address is registered for
|
|
it. So the browser signs in on Microsoft's device code page, with the
|
|
code already filled in, while the launcher polls for the result and
|
|
comes back to the front once it is there.
|
|
|
|
Every entry point goes through it, since they all end up at
|
|
`AccountsCard.login()`. The webview is still one click away in that
|
|
modal for anyone the browser does not work out for.
|
|
---
|
|
.../src/components/ui/AccountsCard.vue | 21 +-
|
|
.../src/components/ui/MicrosoftLoginModal.vue | 219 ++++++++++++++++++
|
|
.../MinecraftRequiredModal.vue | 29 +--
|
|
apps/app-frontend/src/helpers/auth.js | 23 ++
|
|
apps/app/build.rs | 2 +
|
|
apps/app/src/api/auth.rs | 64 +++++
|
|
packages/app-lib/src/api/minecraft_auth.rs | 30 +++
|
|
packages/app-lib/src/api/mod.rs | 12 +-
|
|
packages/app-lib/src/state/minecraft_auth.rs | 145 +++++++++++-
|
|
9 files changed, 499 insertions(+), 46 deletions(-)
|
|
create mode 100644 apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
|
|
|
|
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
|
index 35c21fa..224d776 100644
|
|
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
|
|
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
|
@@ -105,6 +105,7 @@
|
|
</div>
|
|
</div>
|
|
</Accordion>
|
|
+ <MicrosoftLoginModal ref="microsoftLoginModal" @created="accountAdded" />
|
|
<OfflineAccountModal ref="offlineAccountModal" @created="accountAdded" />
|
|
<ElyAccountModal ref="elyAccountModal" @created="accountAdded" />
|
|
</template>
|
|
@@ -133,13 +134,12 @@ import type { Ref } from 'vue'
|
|
import { computed, onUnmounted, ref } from 'vue'
|
|
|
|
import ElyAccountModal from '@/components/ui/ElyAccountModal.vue'
|
|
+import MicrosoftLoginModal from '@/components/ui/MicrosoftLoginModal.vue'
|
|
import OfflineAccountModal from '@/components/ui/OfflineAccountModal.vue'
|
|
import { useAppEvent } from '@/composables/use-app-event'
|
|
-import { handleSevereError } from '@/composables/use-error.js'
|
|
import { trackEvent } from '@/helpers/analytics'
|
|
import {
|
|
get_default_user,
|
|
- login as login_flow,
|
|
remove_user,
|
|
set_default_user,
|
|
users,
|
|
@@ -165,6 +165,7 @@ type MinecraftCredential = {
|
|
const accounts: Ref<MinecraftCredential[]> = ref([])
|
|
const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
|
|
const elyAccountModal = ref<InstanceType<typeof ElyAccountModal>>()
|
|
+const microsoftLoginModal = ref<InstanceType<typeof MicrosoftLoginModal>>()
|
|
const loginDisabled = ref(false)
|
|
const defaultUser = ref<string | undefined>()
|
|
const equippedSkin = ref<Skin | null>(null)
|
|
@@ -275,16 +276,8 @@ async function setAccount(account: MinecraftCredential) {
|
|
emit('change')
|
|
}
|
|
|
|
-async function login() {
|
|
- loginDisabled.value = true
|
|
- const loggedIn = await login_flow().catch(handleSevereError)
|
|
-
|
|
- if (loggedIn) {
|
|
- await setAccount(loggedIn)
|
|
- }
|
|
-
|
|
- trackEvent('AccountLogIn')
|
|
- loginDisabled.value = false
|
|
+function login(event?: MouseEvent) {
|
|
+ microsoftLoginModal.value?.show(event)
|
|
}
|
|
|
|
async function accountAdded() {
|
|
@@ -316,8 +309,8 @@ const messages = defineMessages({
|
|
defaultMessage: 'Not signed in',
|
|
},
|
|
addAccount: {
|
|
- id: 'minecraft-account.add-account',
|
|
- defaultMessage: 'Add account',
|
|
+ id: 'minecraft-account.add-microsoft-account',
|
|
+ defaultMessage: 'Add Microsoft account',
|
|
},
|
|
addOfflineAccount: {
|
|
id: 'minecraft-account.add-offline-account',
|
|
diff --git a/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
|
|
new file mode 100644
|
|
index 0000000..d587736
|
|
--- /dev/null
|
|
+++ b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
|
|
@@ -0,0 +1,219 @@
|
|
+<template>
|
|
+ <NewModal
|
|
+ ref="modal"
|
|
+ :header="formatMessage(messages.header)"
|
|
+ max-width="480px"
|
|
+ width="100%"
|
|
+ :on-hide="stop"
|
|
+ >
|
|
+ <div class="flex flex-col gap-4">
|
|
+ <p class="m-0 leading-tight text-secondary">
|
|
+ {{ formatMessage(messages.description) }}
|
|
+ </p>
|
|
+
|
|
+ <div v-if="code" class="flex flex-col items-center gap-1 rounded-2xl bg-surface-2 px-4 py-3">
|
|
+ <span class="text-sm text-secondary">{{ formatMessage(messages.codeLabel) }}</span>
|
|
+ <div class="flex items-center gap-2">
|
|
+ <span class="select-all font-mono text-2xl font-bold tracking-widest text-contrast">
|
|
+ {{ code.user_code }}
|
|
+ </span>
|
|
+ <IconButton
|
|
+ v-tooltip="formatMessage(messages.copyCode)"
|
|
+ type="quiet"
|
|
+ size="sm"
|
|
+ :label="formatMessage(messages.copyCode)"
|
|
+ @click="copyCode"
|
|
+ >
|
|
+ <CheckIcon v-if="copied" />
|
|
+ <CopyIcon v-else />
|
|
+ </IconButton>
|
|
+ </div>
|
|
+ </div>
|
|
+
|
|
+ <p v-if="error" class="m-0 text-sm leading-tight text-red">{{ error }}</p>
|
|
+ <p v-else class="m-0 flex items-center gap-2 leading-tight text-secondary">
|
|
+ <SpinnerIcon aria-hidden="true" class="animate-spin" />
|
|
+ {{ formatMessage(code ? messages.waiting : messages.opening) }}
|
|
+ </p>
|
|
+
|
|
+ <button
|
|
+ class="button-base m-0 cursor-pointer border-0 bg-transparent p-0 text-left text-sm text-secondary underline"
|
|
+ type="button"
|
|
+ @click="useBuiltInWindow"
|
|
+ >
|
|
+ {{ formatMessage(messages.useBuiltInWindow) }}
|
|
+ </button>
|
|
+ </div>
|
|
+
|
|
+ <template #actions>
|
|
+ <div class="flex justify-end gap-2">
|
|
+ <Button native-type="button" @click="modal?.hide()">
|
|
+ <XIcon aria-hidden="true" />
|
|
+ {{ formatMessage(commonMessages.cancelButton) }}
|
|
+ </Button>
|
|
+ <Button native-type="button" :disabled="opening" @click="openBrowser">
|
|
+ <SpinnerIcon v-if="opening" aria-hidden="true" class="animate-spin" />
|
|
+ <ExternalIcon v-else aria-hidden="true" />
|
|
+ {{ formatMessage(messages.openAgain) }}
|
|
+ </Button>
|
|
+ </div>
|
|
+ </template>
|
|
+ </NewModal>
|
|
+</template>
|
|
+
|
|
+<script setup lang="ts">
|
|
+import { CheckIcon, CopyIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
|
+import {
|
|
+ Button,
|
|
+ commonMessages,
|
|
+ defineMessages,
|
|
+ IconButton,
|
|
+ NewModal,
|
|
+ useVIntl,
|
|
+} from '@modrinth/ui'
|
|
+import { ref } from 'vue'
|
|
+
|
|
+import { handleSevereError } from '@/composables/use-error.js'
|
|
+import { login as builtInLogin, login_device_begin, login_device_poll } from '@/helpers/auth'
|
|
+
|
|
+type DeviceCode = {
|
|
+ user_code: string
|
|
+ device_code: string
|
|
+ verification_uri: string
|
|
+ interval: number
|
|
+ expires_in: number
|
|
+}
|
|
+
|
|
+const { formatMessage } = useVIntl()
|
|
+
|
|
+const emit = defineEmits<{
|
|
+ created: [account: unknown]
|
|
+}>()
|
|
+
|
|
+const modal = ref<InstanceType<typeof NewModal>>()
|
|
+const code = ref<DeviceCode | null>(null)
|
|
+const error = ref('')
|
|
+const opening = ref(false)
|
|
+const copied = ref(false)
|
|
+
|
|
+// Bumped whenever the sign-in starts over or is given up on, so that a poll
|
|
+// from before does not carry on.
|
|
+let attempt = 0
|
|
+let pollTimeout: ReturnType<typeof setTimeout> | undefined
|
|
+
|
|
+function show(event?: MouseEvent) {
|
|
+ modal.value?.show(event)
|
|
+ void openBrowser()
|
|
+}
|
|
+
|
|
+function stop() {
|
|
+ attempt++
|
|
+ clearTimeout(pollTimeout)
|
|
+}
|
|
+
|
|
+async function openBrowser() {
|
|
+ stop()
|
|
+ const current = attempt
|
|
+ code.value = null
|
|
+ error.value = ''
|
|
+ copied.value = false
|
|
+ opening.value = true
|
|
+
|
|
+ try {
|
|
+ const started = (await login_device_begin()) as DeviceCode
|
|
+ if (current !== attempt) return
|
|
+ code.value = started
|
|
+ schedulePoll(current, started)
|
|
+ } catch (e) {
|
|
+ if (current === attempt) error.value = messageOf(e, messages.openError)
|
|
+ } finally {
|
|
+ if (current === attempt) opening.value = false
|
|
+ }
|
|
+}
|
|
+
|
|
+function schedulePoll(current: number, started: DeviceCode) {
|
|
+ pollTimeout = setTimeout(() => void poll(current, started), started.interval * 1000)
|
|
+}
|
|
+
|
|
+async function poll(current: number, started: DeviceCode) {
|
|
+ try {
|
|
+ const account = await login_device_poll(started.device_code)
|
|
+ if (account) {
|
|
+ // The account exists now, so it is announced even if the dialog was
|
|
+ // closed in the meantime.
|
|
+ if (current === attempt) modal.value?.hide()
|
|
+ emit('created', account)
|
|
+ } else if (current === attempt) {
|
|
+ schedulePoll(current, started)
|
|
+ }
|
|
+ } catch (e) {
|
|
+ if (current === attempt) error.value = messageOf(e, messages.genericError)
|
|
+ }
|
|
+}
|
|
+
|
|
+async function copyCode() {
|
|
+ if (!code.value) return
|
|
+ await navigator.clipboard.writeText(code.value.user_code)
|
|
+ copied.value = true
|
|
+ setTimeout(() => (copied.value = false), 1500)
|
|
+}
|
|
+
|
|
+// The window the launcher opens itself, for when the browser will not do.
|
|
+async function useBuiltInWindow() {
|
|
+ modal.value?.hide()
|
|
+
|
|
+ const account = await builtInLogin().catch(handleSevereError)
|
|
+ if (account) emit('created', account)
|
|
+}
|
|
+
|
|
+function messageOf(e: unknown, fallback: { id: string; defaultMessage: string }) {
|
|
+ if (typeof e === 'string') return e
|
|
+ return (e as Error)?.message ?? formatMessage(fallback)
|
|
+}
|
|
+
|
|
+defineExpose({ show })
|
|
+
|
|
+const messages = defineMessages({
|
|
+ header: {
|
|
+ id: 'app.microsoft-login.header',
|
|
+ defaultMessage: 'Sign in to Microsoft',
|
|
+ },
|
|
+ description: {
|
|
+ id: 'app.microsoft-login.description',
|
|
+ defaultMessage:
|
|
+ 'Sign in to Microsoft in the browser that just opened, where your password manager and passkeys work as usual. Once you are done, you are brought back here.',
|
|
+ },
|
|
+ codeLabel: {
|
|
+ id: 'app.microsoft-login.code-label',
|
|
+ defaultMessage: 'If Microsoft asks for a code, enter',
|
|
+ },
|
|
+ copyCode: {
|
|
+ id: 'app.microsoft-login.copy-code',
|
|
+ defaultMessage: 'Copy code',
|
|
+ },
|
|
+ opening: {
|
|
+ id: 'app.microsoft-login.opening',
|
|
+ defaultMessage: 'Opening your browser…',
|
|
+ },
|
|
+ waiting: {
|
|
+ id: 'app.microsoft-login.waiting',
|
|
+ defaultMessage: 'Waiting for you to sign in…',
|
|
+ },
|
|
+ openAgain: {
|
|
+ id: 'app.microsoft-login.open-again',
|
|
+ defaultMessage: 'Open browser again',
|
|
+ },
|
|
+ useBuiltInWindow: {
|
|
+ id: 'app.microsoft-login.use-built-in-window',
|
|
+ defaultMessage: 'Trouble with the browser? Sign in in a window here instead.',
|
|
+ },
|
|
+ openError: {
|
|
+ id: 'app.microsoft-login.open-error',
|
|
+ defaultMessage: 'Could not open a browser to sign in with.',
|
|
+ },
|
|
+ genericError: {
|
|
+ id: 'app.microsoft-login.generic-error',
|
|
+ defaultMessage: 'Could not sign in to Microsoft.',
|
|
+ },
|
|
+})
|
|
+</script>
|
|
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
|
index 7781cee..341ccdc 100644
|
|
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
|
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
|
@@ -25,10 +25,8 @@
|
|
<MessagesSquareIcon />
|
|
{{ formatMessage(messages.getSupport) }}
|
|
</ButtonLink>
|
|
- <Button type="colored" color="brand" :disabled="loadingSignIn" @click="signIn">
|
|
- <SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
|
|
+ <Button type="colored" color="brand" @click="signIn">
|
|
<svg
|
|
- v-else
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
@@ -73,15 +71,12 @@
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
-import { KeyIcon, MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
|
+import { KeyIcon, MessagesSquareIcon, UserIcon } from '@modrinth/assets'
|
|
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
|
import { inject, type Ref, ref } from 'vue'
|
|
|
|
import steveImage from '@/assets/steve-look-up-left.webp'
|
|
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
|
-import { handleSevereError } from '@/composables/use-error.js'
|
|
-import { trackEvent } from '@/helpers/analytics'
|
|
-import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
|
|
|
|
const { formatMessage } = useVIntl()
|
|
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
|
|
@@ -132,28 +127,14 @@ const messages = defineMessages({
|
|
})
|
|
|
|
const modal = ref<InstanceType<typeof NewModal>>()
|
|
-const loadingSignIn = ref(false)
|
|
|
|
function show() {
|
|
modal.value?.show()
|
|
}
|
|
|
|
-async function signIn() {
|
|
- loadingSignIn.value = true
|
|
-
|
|
- try {
|
|
- const loggedIn = await loginFlow()
|
|
- if (!loggedIn) return
|
|
-
|
|
- await set_default_user(loggedIn.profile.id)
|
|
- await accountsCard.value?.refreshValues()
|
|
- await trackEvent('AccountLogIn', { source: 'MinecraftRequiredModal' })
|
|
- modal.value?.hide()
|
|
- } catch (error) {
|
|
- handleSevereError(error)
|
|
- } finally {
|
|
- loadingSignIn.value = false
|
|
- }
|
|
+function signIn(event: MouseEvent) {
|
|
+ modal.value?.hide()
|
|
+ accountsCard.value?.login(event)
|
|
}
|
|
|
|
function addOfflineAccount(event: MouseEvent) {
|
|
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
|
|
index 57580ff..9b2408e 100644
|
|
--- a/apps/app-frontend/src/helpers/auth.js
|
|
+++ b/apps/app-frontend/src/helpers/auth.js
|
|
@@ -33,6 +33,29 @@ export async function login() {
|
|
return await invoke('plugin:auth|login')
|
|
}
|
|
|
|
+/**
|
|
+ * Starts a Microsoft sign-in in the default browser.
|
|
+ *
|
|
+ * Opens Microsoft's sign-in page with the code already filled in. Unlike the
|
|
+ * window the launcher opens itself, the browser has the player's password
|
|
+ * manager, autofill and passkeys.
|
|
+ *
|
|
+ * @returns {Promise<object>} the code, to show and to poll {@link login_device_poll} with
|
|
+ */
|
|
+export async function login_device_begin() {
|
|
+ return await invoke('plugin:auth|login_device_begin')
|
|
+}
|
|
+
|
|
+/**
|
|
+ * Checks on a browser sign-in.
|
|
+ *
|
|
+ * @param {string} deviceCode the `device_code` from {@link login_device_begin}
|
|
+ * @returns {Promise<Credential | null>} the new account, or null while the player is still signing in
|
|
+ */
|
|
+export async function login_device_poll(deviceCode) {
|
|
+ return await invoke('plugin:auth|login_device_poll', { deviceCode })
|
|
+}
|
|
+
|
|
/**
|
|
* Adds an offline account with the given username and makes it the active one.
|
|
*
|
|
diff --git a/apps/app/build.rs b/apps/app/build.rs
|
|
index 24db197..3dbf3dc 100644
|
|
--- a/apps/app/build.rs
|
|
+++ b/apps/app/build.rs
|
|
@@ -14,6 +14,8 @@ fn main() {
|
|
.commands(&[
|
|
"check_reachable",
|
|
"login",
|
|
+ "login_device_begin",
|
|
+ "login_device_poll",
|
|
"login_offline",
|
|
"login_ely",
|
|
"remove_user",
|
|
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
|
|
index dea07b2..eab604d 100644
|
|
--- a/apps/app/src/api/auth.rs
|
|
+++ b/apps/app/src/api/auth.rs
|
|
@@ -2,6 +2,7 @@ use crate::api::Result;
|
|
use chrono::{Duration, Utc};
|
|
use tauri::plugin::TauriPlugin;
|
|
use tauri::{Manager, Runtime, UserAttentionType};
|
|
+use tauri_plugin_opener::OpenerExt;
|
|
use theseus::prelude::*;
|
|
|
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|
@@ -9,6 +10,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|
.invoke_handler(tauri::generate_handler![
|
|
check_reachable,
|
|
login,
|
|
+ login_device_begin,
|
|
+ login_device_poll,
|
|
login_offline,
|
|
login_ely,
|
|
remove_user,
|
|
@@ -88,6 +91,67 @@ pub async fn login<R: Runtime>(
|
|
Ok(None)
|
|
}
|
|
|
|
+/// Starts a Microsoft sign-in in the player's own browser.
|
|
+///
|
|
+/// The window the launcher opens for this has no password manager, no
|
|
+/// autofill and no passkeys, which makes the launcher the one place a player
|
|
+/// cannot sign in the way they sign in everywhere else. Their own browser has
|
|
+/// all of it.
|
|
+///
|
|
+/// This client id has no redirect the launcher could listen on, so the browser
|
|
+/// cannot hand a code back. It signs in on Microsoft's device code page
|
|
+/// instead, with the code already filled in, while [`login_device_poll`] asks
|
|
+/// Microsoft whether that has happened yet.
|
|
+#[tauri::command]
|
|
+pub async fn login_device_begin<R: Runtime>(
|
|
+ app: tauri::AppHandle<R>,
|
|
+) -> Result<MinecraftDeviceCode> {
|
|
+ let code = minecraft_auth::begin_device_login().await?;
|
|
+
|
|
+ let mut url = url::Url::parse(&code.verification_uri).map_err(|_| {
|
|
+ theseus::ErrorKind::OtherError(
|
|
+ "Error parsing the sign-in address".to_string(),
|
|
+ )
|
|
+ .as_error()
|
|
+ })?;
|
|
+ url.query_pairs_mut().append_pair("otc", &code.user_code);
|
|
+
|
|
+ app.opener()
|
|
+ .open_url(url.as_str(), None::<String>)
|
|
+ .map_err(|error| {
|
|
+ theseus::ErrorKind::OtherError(format!(
|
|
+ "Could not open a browser to sign in with: {error}"
|
|
+ ))
|
|
+ .as_error()
|
|
+ })?;
|
|
+
|
|
+ Ok(code)
|
|
+}
|
|
+
|
|
+/// Checks on a sign-in started with [`login_device_begin`]: `None` until the
|
|
+/// player has finished it in the browser, after which the launcher comes back
|
|
+/// to the front.
|
|
+#[tauri::command]
|
|
+pub async fn login_device_poll<R: Runtime>(
|
|
+ app: tauri::AppHandle<R>,
|
|
+ device_code: String,
|
|
+) -> Result<Option<Credentials>> {
|
|
+ let credentials = minecraft_auth::poll_device_login(&device_code).await?;
|
|
+
|
|
+ if credentials.is_some()
|
|
+ && let Some(window) = app.get_webview_window("main")
|
|
+ {
|
|
+ // Best effort: a desktop may keep a window in the background from
|
|
+ // taking focus, in which case it at least asks for attention.
|
|
+ let _ = window.unminimize();
|
|
+ let _ = window.set_focus();
|
|
+ let _ = window
|
|
+ .request_user_attention(Some(UserAttentionType::Informational));
|
|
+ }
|
|
+
|
|
+ Ok(credentials)
|
|
+}
|
|
+
|
|
/// Adds an offline account with the given username and makes it active.
|
|
#[tauri::command]
|
|
pub async fn login_offline(username: String) -> Result<Credentials> {
|
|
diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs
|
|
index 2d18da3..fba473f 100644
|
|
--- a/packages/app-lib/src/api/minecraft_auth.rs
|
|
+++ b/packages/app-lib/src/api/minecraft_auth.rs
|
|
@@ -47,6 +47,36 @@ pub async fn finish_login(
|
|
Ok(credentials)
|
|
}
|
|
|
|
+/// Starts a sign-in in the player's own browser. See [`poll_device_login`].
|
|
+#[tracing::instrument]
|
|
+pub async fn begin_device_login()
|
|
+-> crate::Result<crate::state::MinecraftDeviceCode> {
|
|
+ crate::state::login_device_begin().await
|
|
+}
|
|
+
|
|
+/// Checks on a sign-in started with [`begin_device_login`]: `None` until the
|
|
+/// player has finished it in the browser.
|
|
+#[tracing::instrument(skip(device_code))]
|
|
+pub async fn poll_device_login(
|
|
+ device_code: &str,
|
|
+) -> crate::Result<Option<Credentials>> {
|
|
+ let state = State::get().await?;
|
|
+
|
|
+ let credentials =
|
|
+ crate::state::login_device_poll(device_code, &state.pool).await?;
|
|
+
|
|
+ if credentials.is_some()
|
|
+ && let Err(error) =
|
|
+ crate::onboarding_checklist::mark_logged_into_minecraft().await
|
|
+ {
|
|
+ tracing::warn!(
|
|
+ "Failed to mark Minecraft login in onboarding checklist: {error}"
|
|
+ );
|
|
+ }
|
|
+
|
|
+ Ok(credentials)
|
|
+}
|
|
+
|
|
/// Creates an offline account for `username`, or reuses the existing one, and
|
|
/// makes it the active account.
|
|
///
|
|
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
|
|
index 320112f..ba6c342 100644
|
|
--- a/packages/app-lib/src/api/mod.rs
|
|
+++ b/packages/app-lib/src/api/mod.rs
|
|
@@ -28,12 +28,12 @@ pub mod data {
|
|
InstanceInstallCandidate, InstanceInstallTarget,
|
|
InstanceLaunchOverridesPatch, InstanceLink, InstanceMetadata,
|
|
InstanceSyncedOption, InstanceSyncedOptions, InstanceTabVisibility,
|
|
- JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
|
|
- ModrinthCredentials, OnboardingChecklist, Organization, OwnerType,
|
|
- ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult,
|
|
- SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment,
|
|
- SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version,
|
|
- WindowSize,
|
|
+ JavaVersion, LinkedModpackInfo, MemorySettings, MinecraftDeviceCode,
|
|
+ MinecraftLoginFlow, ModLoader, ModrinthCredentials, OnboardingChecklist,
|
|
+ Organization, OwnerType, ProcessMetadata, Project, ProjectType,
|
|
+ ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings,
|
|
+ SharedInstanceAttachment, SharedInstanceRole, TeamMember, Theme, User,
|
|
+ UserFriend, Version, WindowSize,
|
|
};
|
|
pub use ariadne::users::UserStatus;
|
|
pub use modrinth_content_management::{
|
|
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
|
|
index 4130488..1331a24 100644
|
|
--- a/packages/app-lib/src/state/minecraft_auth.rs
|
|
+++ b/packages/app-lib/src/state/minecraft_auth.rs
|
|
@@ -36,6 +36,7 @@ use uuid::Uuid;
|
|
pub enum MinecraftAuthStep {
|
|
GetDeviceToken,
|
|
SisuAuthenticate,
|
|
+ GetDeviceCode,
|
|
GetOAuthToken,
|
|
RefreshOAuthToken,
|
|
SisuAuthorize,
|
|
@@ -107,6 +108,16 @@ pub struct MinecraftLoginFlow {
|
|
pub auth_request_uri: String,
|
|
}
|
|
|
|
+/// A sign-in in the player's own browser, on Microsoft's device code page.
|
|
+#[derive(Serialize, Deserialize, Debug)]
|
|
+pub struct MinecraftDeviceCode {
|
|
+ pub user_code: String,
|
|
+ pub device_code: String,
|
|
+ pub verification_uri: String,
|
|
+ pub interval: u64,
|
|
+ pub expires_in: u64,
|
|
+}
|
|
+
|
|
#[tracing::instrument]
|
|
pub async fn login_begin(
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
|
@@ -143,13 +154,37 @@ pub async fn login_finish(
|
|
code: &str,
|
|
flow: MinecraftLoginFlow,
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
|
+) -> crate::Result<Credentials> {
|
|
+ let oauth_token = oauth_token(code, &flow.verifier).await?;
|
|
+ login_with_oauth_token(Some(&flow.session_id), oauth_token, exec).await
|
|
+}
|
|
+
|
|
+/// Checks on a sign-in in the player's own browser: `None` until they have
|
|
+/// finished it there.
|
|
+#[tracing::instrument(skip(device_code))]
|
|
+pub async fn login_device_poll(
|
|
+ device_code: &str,
|
|
+ exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
|
+) -> crate::Result<Option<Credentials>> {
|
|
+ match oauth_device_token(device_code).await? {
|
|
+ Some(oauth_token) => login_with_oauth_token(None, oauth_token, exec)
|
|
+ .await
|
|
+ .map(Some),
|
|
+ None => Ok(None),
|
|
+ }
|
|
+}
|
|
+
|
|
+/// Turns a Microsoft token into a Minecraft account and saves it.
|
|
+async fn login_with_oauth_token(
|
|
+ session_id: Option<&str>,
|
|
+ oauth_token: RequestWithDate<OAuthToken>,
|
|
+ exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
|
) -> crate::Result<Credentials> {
|
|
let (pair, _) =
|
|
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
|
|
|
|
- let oauth_token = oauth_token(code, &flow.verifier).await?;
|
|
let sisu_authorize = sisu_authorize(
|
|
- Some(&flow.session_id),
|
|
+ session_id,
|
|
&oauth_token.value.access_token,
|
|
&pair.token.token,
|
|
&pair.key,
|
|
@@ -1271,6 +1306,112 @@ async fn oauth_token(
|
|
})
|
|
}
|
|
|
|
+/// Starts a sign-in on Microsoft's device code page.
|
|
+#[tracing::instrument]
|
|
+pub async fn login_device_begin() -> crate::Result<MinecraftDeviceCode> {
|
|
+ let mut query = HashMap::new();
|
|
+ query.insert("client_id", MICROSOFT_CLIENT_ID);
|
|
+ query.insert("scope", REQUESTED_SCOPE);
|
|
+ query.insert("response_type", "device_code");
|
|
+
|
|
+ let res = auth_retry(|| {
|
|
+ INSECURE_REQWEST_CLIENT
|
|
+ .post("https://login.live.com/oauth20_connect.srf")
|
|
+ .header("Accept", "application/json")
|
|
+ .form(&query)
|
|
+ .send()
|
|
+ })
|
|
+ .await
|
|
+ .map_err(|source| MinecraftAuthenticationError::Request {
|
|
+ source,
|
|
+ step: MinecraftAuthStep::GetDeviceCode,
|
|
+ })?;
|
|
+
|
|
+ let status = res.status();
|
|
+ let text = res.text().await.map_err(|source| {
|
|
+ MinecraftAuthenticationError::Request {
|
|
+ source,
|
|
+ step: MinecraftAuthStep::GetDeviceCode,
|
|
+ }
|
|
+ })?;
|
|
+
|
|
+ let body = serde_json::from_str(&text).map_err(|source| {
|
|
+ MinecraftAuthenticationError::DeserializeResponse {
|
|
+ source,
|
|
+ raw: text,
|
|
+ step: MinecraftAuthStep::GetDeviceCode,
|
|
+ status_code: status,
|
|
+ }
|
|
+ })?;
|
|
+
|
|
+ Ok(body)
|
|
+}
|
|
+
|
|
+/// The token for a device code sign-in, or `None` while the player is still
|
|
+/// signing in.
|
|
+#[tracing::instrument(skip(device_code))]
|
|
+async fn oauth_device_token(
|
|
+ device_code: &str,
|
|
+) -> crate::Result<Option<RequestWithDate<OAuthToken>>> {
|
|
+ let mut query = HashMap::new();
|
|
+ query.insert("client_id", MICROSOFT_CLIENT_ID);
|
|
+ query.insert("device_code", device_code);
|
|
+ query.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
|
+
|
|
+ // Not retried: it is asked again every few seconds anyway.
|
|
+ let res = INSECURE_REQWEST_CLIENT
|
|
+ .post("https://login.live.com/oauth20_token.srf")
|
|
+ .header("Accept", "application/json")
|
|
+ .form(&query)
|
|
+ .send()
|
|
+ .await
|
|
+ .map_err(|source| MinecraftAuthenticationError::Request {
|
|
+ source,
|
|
+ step: MinecraftAuthStep::GetOAuthToken,
|
|
+ })?;
|
|
+
|
|
+ let status = res.status();
|
|
+ let current_date = get_date_header(res.headers());
|
|
+ let text = res.text().await.map_err(|source| {
|
|
+ MinecraftAuthenticationError::Request {
|
|
+ source,
|
|
+ step: MinecraftAuthStep::GetOAuthToken,
|
|
+ }
|
|
+ })?;
|
|
+
|
|
+ if !status.is_success()
|
|
+ && let Ok(response) = serde_json::from_str::<OAuthErrorResponse>(&text)
|
|
+ {
|
|
+ let message = match response.error.as_str() {
|
|
+ "authorization_pending" | "slow_down" => return Ok(None),
|
|
+ "expired_token" => {
|
|
+ "The sign-in took too long. Open the browser again to start over."
|
|
+ }
|
|
+ "authorization_declined" | "access_denied" => {
|
|
+ "The sign-in was cancelled in the browser."
|
|
+ }
|
|
+ _ => "",
|
|
+ };
|
|
+ if !message.is_empty() {
|
|
+ return Err(ErrorKind::OtherError(message.to_string()).into());
|
|
+ }
|
|
+ }
|
|
+
|
|
+ let body = serde_json::from_str(&text).map_err(|source| {
|
|
+ MinecraftAuthenticationError::DeserializeResponse {
|
|
+ source,
|
|
+ raw: text,
|
|
+ step: MinecraftAuthStep::GetOAuthToken,
|
|
+ status_code: status,
|
|
+ }
|
|
+ })?;
|
|
+
|
|
+ Ok(Some(RequestWithDate {
|
|
+ date: current_date,
|
|
+ value: body,
|
|
+ }))
|
|
+}
|
|
+
|
|
#[tracing::instrument]
|
|
async fn oauth_refresh(
|
|
refresh_token: &str,
|