feat(app): better external browser Modrinth login flow (#4033)

* fix(app-frontend): do not emit exceptions when no loaders are available

* refactor(app): simplify Microsoft login code without functional changes

* feat(app): external browser auth flow for Modrinth account login

* chore: address Clippy lint

* chore(app/oauth_utils): simplify `handle_reply` error handling according to review

* chore(app-lib): simplify `Url` usage out of MC auth module
This commit is contained in:
Alejandro González 2025-07-23 00:55:18 +02:00 committed by GitHub
parent 0e0ca1971a
commit 32793c50e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 389 additions and 145 deletions

2
Cargo.lock generated
View File

@ -8985,6 +8985,8 @@ dependencies = [
"dashmap", "dashmap",
"either", "either",
"enumset", "enumset",
"hyper 1.6.0",
"hyper-util",
"native-dialog", "native-dialog",
"paste", "paste",
"serde", "serde",

View File

@ -67,6 +67,7 @@ heck = "0.5.0"
hex = "0.4.3" hex = "0.4.3"
hickory-resolver = "0.25.2" hickory-resolver = "0.25.2"
hmac = "0.12.1" hmac = "0.12.1"
hyper = "1.6.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [ hyper-rustls = { version = "0.27.7", default-features = false, features = [
"http1", "http1",
"native-tokio", "native-tokio",

View File

@ -61,9 +61,10 @@ import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js' import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater' import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue' import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js' import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js' import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue' import FriendsList from '@/components/ui/friends/FriendsList.vue'
@ -263,6 +264,8 @@ const incompatibilityWarningModal = ref()
const credentials = ref() const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() { async function fetchCredentials() {
const creds = await getCreds().catch(handleError) const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) { if (creds && creds.user_id) {
@ -272,8 +275,24 @@ async function fetchCredentials() {
} }
async function signIn() { async function signIn() {
await login().catch(handleError) modrinthLoginFlowWaitModal.value.show()
await fetchCredentials()
try {
await login()
await fetchCredentials()
} catch (error) {
if (
typeof error === 'object' &&
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// Not really an error due to being a result of user interaction, show nothing
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
} }
async function logOut() { async function logOut() {
@ -402,6 +421,9 @@ function handleAuxClick(e) {
<Suspense> <Suspense>
<AppSettingsModal ref="settingsModal" /> <AppSettingsModal ref="settingsModal" />
</Suspense> </Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
</Suspense>
<Suspense> <Suspense>
<InstanceCreationModal ref="installationModal" /> <InstanceCreationModal ref="installationModal" />
</Suspense> </Suspense>

View File

@ -305,12 +305,16 @@ const [
get_game_versions().then(shallowRef).catch(handleError), get_game_versions().then(shallowRef).catch(handleError),
get_loaders() get_loaders()
.then((value) => .then((value) =>
value ref(
.filter((item) => item.supported_project_types.includes('modpack')) value
.map((item) => item.name.toLowerCase()), .filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
) )
.then(ref) .catch((err) => {
.catch(handleError), handleError(err)
return ref([])
}),
]) ])
loaders.value.unshift('vanilla') loaders.value.unshift('vanilla')

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue.
</p>
</ModalWrapper>
</template>

View File

@ -16,3 +16,7 @@ export async function logout() {
export async function get() { export async function get() {
return await invoke('plugin:mr-auth|get') return await invoke('plugin:mr-auth|get')
} }
export async function cancelLogin() {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}

View File

@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there."); println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?; let login = minecraft_auth::begin_login().await?;
println!("Open URL {} in a browser", login.redirect_uri.as_str()); println!("Open URL {} in a browser", login.auth_request_uri.as_str());
println!("Please enter URL code: "); println!("Please enter URL code: ");
let mut input = String::new(); let mut input = String::new();

View File

@ -31,6 +31,8 @@ thiserror.workspace = true
daedalus.workspace = true daedalus.workspace = true
chrono.workspace = true chrono.workspace = true
either.workspace = true either.workspace = true
hyper = { workspace = true, features = ["server"] }
hyper-util.workspace = true
url.workspace = true url.workspace = true
urlencoding.workspace = true urlencoding.workspace = true

View File

@ -120,7 +120,12 @@ fn main() {
.plugin( .plugin(
"mr-auth", "mr-auth",
InlinedPlugin::new() InlinedPlugin::new()
.commands(&["modrinth_login", "logout", "get"]) .commands(&[
"modrinth_login",
"logout",
"get",
"cancel_modrinth_login",
])
.default_permission( .default_permission(
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,
), ),

View File

@ -33,7 +33,7 @@ pub async fn login<R: Runtime>(
let window = tauri::WebviewWindowBuilder::new( let window = tauri::WebviewWindowBuilder::new(
&app, &app,
"signin", "signin",
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err( tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|_| { |_| {
theseus::ErrorKind::OtherError( theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(), "Error parsing auth redirect URL".to_string(),
@ -77,6 +77,7 @@ pub async fn login<R: Runtime>(
window.close()?; window.close()?;
Ok(None) Ok(None)
} }
#[tauri::command] #[tauri::command]
pub async fn remove_user(user: uuid::Uuid) -> Result<()> { pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?) Ok(minecraft_auth::remove_user(user).await?)

View File

@ -22,6 +22,8 @@ pub mod cache;
pub mod friends; pub mod friends;
pub mod worlds; pub mod worlds;
mod oauth_utils;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>; pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
// // Main returnable Theseus GUI error // // Main returnable Theseus GUI error

View File

@ -1,79 +1,70 @@
use crate::api::Result; use crate::api::Result;
use chrono::{Duration, Utc}; use crate::api::TheseusSerializableError;
use crate::api::oauth_utils;
use tauri::Manager;
use tauri::Runtime;
use tauri::plugin::TauriPlugin; use tauri::plugin::TauriPlugin;
use tauri::{Manager, Runtime, UserAttentionType}; use tauri_plugin_opener::OpenerExt;
use theseus::prelude::*; use theseus::prelude::*;
use tokio::sync::oneshot;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> { pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("mr-auth") tauri::plugin::Builder::new("mr-auth")
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,]) .invoke_handler(tauri::generate_handler![
modrinth_login,
logout,
get,
cancel_modrinth_login,
])
.build() .build()
} }
#[tauri::command] #[tauri::command]
pub async fn modrinth_login<R: Runtime>( pub async fn modrinth_login<R: Runtime>(
app: tauri::AppHandle<R>, app: tauri::AppHandle<R>,
) -> Result<Option<ModrinthCredentials>> { ) -> Result<ModrinthCredentials> {
let redirect_uri = mr_auth::authenticate_begin_flow(); let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
auth_code_recv_socket_tx,
));
let start = Utc::now(); let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
if let Some(window) = app.get_webview_window("modrinth-signin") { let auth_request_uri = format!(
window.close()?; "{}?launcher=true&ipver={}&port={}",
} mr_auth::authenticate_begin_flow(),
if auth_code_recv_socket.is_ipv4() {
"4"
} else {
"6"
},
auth_code_recv_socket.port()
);
let window = tauri::WebviewWindowBuilder::new( app.opener()
&app, .open_url(auth_request_uri, None::<&str>)
"modrinth-signin", .map_err(|e| {
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| { TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError( theseus::ErrorKind::OtherError(format!(
"Error parsing auth redirect URL".to_string(), "Failed to open auth request URI: {e}"
))
.into(),
) )
.as_error() })?;
})?),
)
.min_inner_size(420.0, 632.0)
.inner_size(420.0, 632.0)
.max_inner_size(420.0, 632.0)
.zoom_hotkeys_enabled(false)
.title("Sign into Modrinth")
.always_on_top(true)
.center()
.build()?;
window.request_user_attention(Some(UserAttentionType::Critical))?; let Some(auth_code) = auth_code.await.unwrap()? else {
return Err(TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
));
};
while (Utc::now() - start) < Duration::minutes(10) { let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
if window.title().is_err() {
// user closed window, cancelling flow
return Ok(None);
}
if window if let Some(main_window) = app.get_window("main") {
.url()? main_window.set_focus().ok();
.as_str()
.starts_with("https://launcher-files.modrinth.com")
{
let url = window.url()?;
let code = url.query_pairs().find(|(key, _)| key == "code");
window.close()?;
return if let Some((_, code)) = code {
let val = mr_auth::authenticate_finish_flow(&code).await?;
Ok(Some(val))
} else {
Ok(None)
};
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
} }
window.close()?; Ok(credentials)
Ok(None)
} }
#[tauri::command] #[tauri::command]
@ -85,3 +76,8 @@ pub async fn logout() -> Result<()> {
pub async fn get() -> Result<Option<ModrinthCredentials>> { pub async fn get() -> Result<Option<ModrinthCredentials>> {
Ok(theseus::mr_auth::get_credentials().await?) Ok(theseus::mr_auth::get_credentials().await?)
} }
#[tauri::command]
pub fn cancel_modrinth_login() {
oauth_utils::auth_code_reply::stop_listeners();
}

View File

@ -0,0 +1,159 @@
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
//!
//! This server is needed for the step 4 of the OAuth authentication dance represented in
//! figure 1 of [RFC 8252].
//!
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
//!
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::{LazyLock, Mutex},
time::Duration,
};
use hyper::body::Incoming;
use hyper_util::rt::{TokioIo, TokioTimer};
use theseus::ErrorKind;
use tokio::{
net::TcpListener,
sync::{broadcast, oneshot},
};
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
LazyLock::new(|| broadcast::channel(1024).0);
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
/// by listening on the counterpart channel for `listen_socket_tx`.
///
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
pub async fn listen(
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
) -> Result<Option<String>, theseus::Error> {
// IPv4 is tried first for the best compatibility and performance with most systems.
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
// to prevent failures deriving from improper name resolution setup. Any available
// ephemeral port is used to prevent conflicts with other services. This is all as per
// RFC 8252's recommendations
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
];
let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await {
Ok(listener) => {
listen_socket_tx
.send(listener.local_addr().map_err(|e| {
ErrorKind::OtherError(format!(
"Failed to get auth code reply socket address: {e}"
))
.into()
}))
.ok();
listener
}
Err(e) => {
let error_msg =
format!("Failed to bind auth code reply socket: {e}");
listen_socket_tx
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
.ok();
return Err(ErrorKind::OtherError(error_msg).into());
}
};
let mut auth_code = Mutex::new(None);
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
while auth_code.get_mut().unwrap().is_none() {
let client_socket = tokio::select! {
biased;
_ = shutdown_notification.recv() => {
break;
}
conn_accept_result = listener.accept() => {
match conn_accept_result {
Ok((socket, _)) => socket,
Err(e) => {
tracing::warn!("Failed to accept auth code reply: {e}");
continue;
}
}
}
};
if let Err(e) = hyper::server::conn::http1::Builder::new()
.keep_alive(false)
.header_read_timeout(Duration::from_secs(5))
.timer(TokioTimer::new())
.auto_date_header(false)
.serve_connection(
TokioIo::new(client_socket),
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
)
.await
{
tracing::warn!("Failed to handle auth code reply: {e}");
}
}
Ok(auth_code.into_inner().unwrap())
}
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
pub fn stop_listeners() {
SERVER_SHUTDOWN.send(()).ok();
}
async fn handle_reply(
req: hyper::Request<Incoming>,
auth_code_out: &Mutex<Option<String>>,
) -> Result<hyper::Response<String>, hyper::http::Error> {
if req.method() != hyper::Method::GET {
return hyper::Response::builder()
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET")
.body("".into());
}
// The authorization code is guaranteed to be sent as a "code" query parameter
// in the request URI query string as per RFC 6749 § 4.1.2
let auth_code = req.uri().query().and_then(|query_string| {
query_string
.split('&')
.filter_map(|query_pair| query_pair.split_once('='))
.find_map(|(key, value)| (key == "code").then_some(value))
});
let response = if let Some(auth_code) = auth_code {
*auth_code_out.lock().unwrap() = Some(auth_code.to_string());
hyper::Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Success")
.replace("{{message}}", "You have successfully signed in! You can close this page now."),
)
} else {
hyper::Response::builder()
.status(hyper::StatusCode::BAD_REQUEST)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Error")
.replace("{{message}}", "Authorization code not found. Please try signing in again."),
)
}?;
Ok(response)
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
//! Assorted utilities for OAuth 2.0 authorization flows.
pub mod auth_code_reply;

View File

@ -63,6 +63,7 @@
"height": 800, "height": 800,
"resizable": true, "resizable": true,
"title": "Modrinth App", "title": "Modrinth App",
"label": "main",
"width": 1280, "width": 1280,
"minHeight": 700, "minHeight": 700,
"minWidth": 1100, "minWidth": 1100,

View File

@ -1,6 +1,12 @@
<template> <template>
<div> <div v-if="subtleLauncherRedirectUri">
<template v-if="flow"> <iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code"> <label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span> <span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description"> <span class="label__description">
@ -189,6 +195,7 @@ const auth = await useAuth();
const route = useNativeRoute(); const route = useNativeRoute();
const redirectTarget = route.query.redirect || ""; const redirectTarget = route.query.redirect || "";
const subtleLauncherRedirectUri = ref();
if (route.query.code && !route.fullPath.includes("new_account=true")) { if (route.query.code && !route.fullPath.includes("new_account=true")) {
await finishSignIn(); await finishSignIn();
@ -262,7 +269,32 @@ async function begin2FASignIn() {
async function finishSignIn(token) { async function finishSignIn(token) {
if (route.query.launcher) { if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true }); if (!token) {
token = auth.value.token;
}
const usesLocalhostRedirectionScheme =
["4", "6"].includes(route.query.ipver) && Number(route.query.port) < 65536;
const redirectUrl = usesLocalhostRedirectionScheme
? `http://${route.query.ipver === "4" ? "127.0.0.1" : "[::1]"}:${route.query.port}/?code=${token}`
: `https://launcher-files.modrinth.com/?code=${token}`;
if (usesLocalhostRedirectionScheme) {
// When using this redirection scheme, the auth token is very visible in the URL to the user.
// While we could make it harder to find with a POST request, such is security by obscurity:
// the user and other applications would still be able to sniff the token in the request body.
// So, to make the UX a little better by not changing the displayed URL, while keeping the
// token hidden from very casual observation and keeping the protocol as close to OAuth's
// standard flows as possible, let's execute the redirect within an iframe that visually
// covers the entire page.
subtleLauncherRedirectUri.value = redirectUrl;
} else {
await navigateTo(redirectUrl, {
external: true,
});
}
return; return;
} }

View File

@ -247,16 +247,14 @@ async function createAccount() {
}, },
}); });
if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
external: true,
});
return;
}
await useAuth(res.session); await useAuth(res.session);
await useUser(); await useUser();
if (route.query.launcher) {
await navigateTo({ path: "/auth/sign-in", query: route.query });
return;
}
if (route.query.redirect) { if (route.query.redirect) {
await navigateTo(route.query.redirect); await navigateTo(route.query.redirect);
} else { } else {

View File

@ -85,21 +85,18 @@ pub struct MinecraftLoginFlow {
pub verifier: String, pub verifier: String,
pub challenge: String, pub challenge: String,
pub session_id: String, pub session_id: String,
pub redirect_uri: String, pub auth_request_uri: String,
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn login_begin( pub async fn login_begin(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<MinecraftLoginFlow> { ) -> crate::Result<MinecraftLoginFlow> {
let (pair, current_date, valid_date) = let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec) DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
.await?;
let verifier = generate_oauth_challenge(); let verifier = generate_oauth_challenge();
let mut hasher = sha2::Sha256::new(); let result = sha2::Sha256::digest(&verifier);
hasher.update(&verifier);
let result = hasher.finalize();
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result); let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
match sisu_authenticate( match sisu_authenticate(
@ -110,46 +107,15 @@ pub async fn login_begin(
) )
.await .await
{ {
Ok((session_id, redirect_uri)) => Ok(MinecraftLoginFlow { Ok((session_id, redirect_uri)) => {
verifier, return Ok(MinecraftLoginFlow {
challenge, verifier,
session_id, challenge,
redirect_uri: redirect_uri.value.msa_oauth_redirect, session_id,
}), auth_request_uri: redirect_uri.value.msa_oauth_redirect,
Err(err) => { });
if !valid_date {
let (pair, current_date, _) =
DeviceTokenPair::refresh_and_get_device_token(
Utc::now(),
false,
exec,
)
.await?;
let verifier = generate_oauth_challenge();
let mut hasher = sha2::Sha256::new();
hasher.update(&verifier);
let result = hasher.finalize();
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
let (session_id, redirect_uri) = sisu_authenticate(
&pair.token.token,
&challenge,
&pair.key,
current_date,
)
.await?;
Ok(MinecraftLoginFlow {
verifier,
challenge,
session_id,
redirect_uri: redirect_uri.value.msa_oauth_redirect,
})
} else {
Err(crate::ErrorKind::from(err).into())
}
} }
Err(err) => return Err(crate::ErrorKind::from(err).into()),
} }
} }
@ -159,9 +125,8 @@ pub async fn login_finish(
flow: MinecraftLoginFlow, flow: MinecraftLoginFlow,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Credentials> { ) -> crate::Result<Credentials> {
let (pair, _, _) = let (pair, _) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec) DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
.await?;
let oauth_token = oauth_token(code, &flow.verifier).await?; let oauth_token = oauth_token(code, &flow.verifier).await?;
let sisu_authorize = sisu_authorize( let sisu_authorize = sisu_authorize(
@ -267,10 +232,9 @@ impl Credentials {
} }
let oauth_token = oauth_refresh(&self.refresh_token).await?; let oauth_token = oauth_refresh(&self.refresh_token).await?;
let (pair, current_date, _) = let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token( DeviceTokenPair::refresh_and_get_device_token(
oauth_token.date, oauth_token.date,
false,
exec, exec,
) )
.await?; .await?;
@ -633,21 +597,20 @@ impl DeviceTokenPair {
#[tracing::instrument(skip(exec))] #[tracing::instrument(skip(exec))]
async fn refresh_and_get_device_token( async fn refresh_and_get_device_token(
current_date: DateTime<Utc>, current_date: DateTime<Utc>,
force_generate: bool,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<(Self, DateTime<Utc>, bool)> { ) -> crate::Result<(Self, DateTime<Utc>)> {
let pair = Self::get(exec).await?; let pair = Self::get(exec).await?;
if let Some(mut pair) = pair { if let Some(mut pair) = pair {
if pair.token.not_after > Utc::now() && !force_generate { if pair.token.not_after > current_date {
Ok((pair, current_date, false)) Ok((pair, current_date))
} else { } else {
let res = device_token(&pair.key, current_date).await?; let res = device_token(&pair.key, current_date).await?;
pair.token = res.value; pair.token = res.value;
pair.upsert(exec).await?; pair.upsert(exec).await?;
Ok((pair, res.date, true)) Ok((pair, res.date))
} }
} else { } else {
let key = generate_key()?; let key = generate_key()?;
@ -660,7 +623,7 @@ impl DeviceTokenPair {
pair.upsert(exec).await?; pair.upsert(exec).await?;
Ok((pair, res.date, true)) Ok((pair, res.date))
} }
} }
@ -758,8 +721,8 @@ impl DeviceTokenPair {
} }
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328"; const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
const REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf"; const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
const REQUESTED_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL"; const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
struct RequestWithDate<T> { struct RequestWithDate<T> {
pub date: DateTime<Utc>, pub date: DateTime<Utc>,
@ -838,7 +801,7 @@ async fn sisu_authenticate(
"AppId": MICROSOFT_CLIENT_ID, "AppId": MICROSOFT_CLIENT_ID,
"DeviceToken": token, "DeviceToken": token,
"Offers": [ "Offers": [
REQUESTED_SCOPES REQUESTED_SCOPE
], ],
"Query": { "Query": {
"code_challenge": challenge, "code_challenge": challenge,
@ -846,7 +809,7 @@ async fn sisu_authenticate(
"state": generate_oauth_challenge(), "state": generate_oauth_challenge(),
"prompt": "select_account" "prompt": "select_account"
}, },
"RedirectUri": REDIRECT_URL, "RedirectUri": AUTH_REPLY_URL,
"Sandbox": "RETAIL", "Sandbox": "RETAIL",
"TokenType": "code", "TokenType": "code",
"TitleId": "1794566092", "TitleId": "1794566092",
@ -890,12 +853,12 @@ async fn oauth_token(
verifier: &str, verifier: &str,
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> { ) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
let mut query = HashMap::new(); let mut query = HashMap::new();
query.insert("client_id", "00000000402b5328"); query.insert("client_id", MICROSOFT_CLIENT_ID);
query.insert("code", code); query.insert("code", code);
query.insert("code_verifier", verifier); query.insert("code_verifier", verifier);
query.insert("grant_type", "authorization_code"); query.insert("grant_type", "authorization_code");
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf"); query.insert("redirect_uri", AUTH_REPLY_URL);
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL"); query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| { let res = auth_retry(|| {
REQWEST_CLIENT REQWEST_CLIENT
@ -939,11 +902,11 @@ async fn oauth_refresh(
refresh_token: &str, refresh_token: &str,
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> { ) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
let mut query = HashMap::new(); let mut query = HashMap::new();
query.insert("client_id", "00000000402b5328"); query.insert("client_id", MICROSOFT_CLIENT_ID);
query.insert("refresh_token", refresh_token); query.insert("refresh_token", refresh_token);
query.insert("grant_type", "refresh_token"); query.insert("grant_type", "refresh_token");
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf"); query.insert("redirect_uri", AUTH_REPLY_URL);
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL"); query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| { let res = auth_retry(|| {
REQWEST_CLIENT REQWEST_CLIENT
@ -1007,7 +970,7 @@ async fn sisu_authorize(
"/authorize", "/authorize",
json!({ json!({
"AccessToken": format!("t={access_token}"), "AccessToken": format!("t={access_token}"),
"AppId": "00000000402b5328", "AppId": MICROSOFT_CLIENT_ID,
"DeviceToken": device_token, "DeviceToken": device_token,
"ProofKey": { "ProofKey": {
"kty": "EC", "kty": "EC",

View File

@ -190,7 +190,7 @@ impl ModrinthCredentials {
} }
pub const fn get_login_url() -> &'static str { pub const fn get_login_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-in?launcher=true") concat!(env!("MODRINTH_URL"), "auth/sign-in")
} }
pub async fn finish_login_flow( pub async fn finish_login_flow(
@ -198,6 +198,12 @@ pub async fn finish_login_flow(
semaphore: &FetchSemaphore, semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<ModrinthCredentials> { ) -> crate::Result<ModrinthCredentials> {
// The authorization code actually is the access token, since Labrinth doesn't
// issue separate authorization codes. Therefore, this is equivalent to an
// implicit OAuth grant flow, and no additional exchanging or finalization is
// needed. TODO not do this for the reasons outlined at
// https://oauth.net/2/grant-types/implicit/
let info = fetch_info(code, semaphore, exec).await?; let info = fetch_info(code, semaphore, exec).await?;
Ok(ModrinthCredentials { Ok(ModrinthCredentials {