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:
parent
0e0ca1971a
commit
32793c50e1
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login()
|
||||||
await fetchCredentials()
|
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>
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
ref(
|
||||||
value
|
value
|
||||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||||
.map((item) => item.name.toLowerCase()),
|
.map((item) => item.name.toLowerCase()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.then(ref)
|
.catch((err) => {
|
||||||
.catch(handleError),
|
handleError(err)
|
||||||
|
return ref([])
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
loaders.value.unshift('vanilla')
|
loaders.value.unshift('vanilla')
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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')
|
||||||
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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?)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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() {
|
||||||
let window = tauri::WebviewWindowBuilder::new(
|
"4"
|
||||||
&app,
|
|
||||||
"modrinth-signin",
|
|
||||||
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| {
|
|
||||||
theseus::ErrorKind::OtherError(
|
|
||||||
"Error parsing auth redirect URL".to_string(),
|
|
||||||
)
|
|
||||||
.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))?;
|
|
||||||
|
|
||||||
while (Utc::now() - start) < Duration::minutes(10) {
|
|
||||||
if window.title().is_err() {
|
|
||||||
// user closed window, cancelling flow
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
if window
|
|
||||||
.url()?
|
|
||||||
.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 {
|
} else {
|
||||||
Ok(None)
|
"6"
|
||||||
|
},
|
||||||
|
auth_code_recv_socket.port()
|
||||||
|
);
|
||||||
|
|
||||||
|
app.opener()
|
||||||
|
.open_url(auth_request_uri, None::<&str>)
|
||||||
|
.map_err(|e| {
|
||||||
|
TheseusSerializableError::Theseus(
|
||||||
|
theseus::ErrorKind::OtherError(format!(
|
||||||
|
"Failed to open auth request URI: {e}"
|
||||||
|
))
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let Some(auth_code) = auth_code.await.unwrap()? else {
|
||||||
|
return Err(TheseusSerializableError::Theseus(
|
||||||
|
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
|
||||||
|
|
||||||
|
if let Some(main_window) = app.get_window("main") {
|
||||||
|
main_window.set_focus().ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
Ok(credentials)
|
||||||
}
|
|
||||||
|
|
||||||
window.close()?;
|
|
||||||
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();
|
||||||
|
}
|
||||||
|
|||||||
159
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal file
159
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal 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)
|
||||||
|
}
|
||||||
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
File diff suppressed because one or more lines are too long
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
//! Assorted utilities for OAuth 2.0 authorization flows.
|
||||||
|
|
||||||
|
pub mod auth_code_reply;
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)) => {
|
||||||
|
return Ok(MinecraftLoginFlow {
|
||||||
verifier,
|
verifier,
|
||||||
challenge,
|
challenge,
|
||||||
session_id,
|
session_id,
|
||||||
redirect_uri: redirect_uri.value.msa_oauth_redirect,
|
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",
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user