mirror of
https://github.com/mat-1/azalea.git
synced 2025-08-02 14:26:04 +00:00
caching*
refreshing microsoft auth tokens isn't implemented yet, also i haven't tested it
This commit is contained in:
parent
cb001fa341
commit
fe1fb2dbfd
5 changed files with 273 additions and 75 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
@ -119,6 +119,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"azalea-buf",
|
||||
"azalea-crypto",
|
||||
"chrono",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"reqwest",
|
||||
|
@ -429,9 +430,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
|||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.19"
|
||||
version = "0.4.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
|
||||
checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
|
@ -818,7 +819,7 @@ checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.10.2+wasi-snapshot-preview1",
|
||||
"wasi 0.10.0+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2096,9 +2097,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.10.2+wasi-snapshot-preview1"
|
||||
version = "0.10.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
|
||||
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
|
|
|
@ -9,16 +9,17 @@ version = "0.1.0"
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1.0.65"
|
||||
azalea-buf = { path = "../azalea-buf", version = "^0.1.0" }
|
||||
azalea-buf = {path = "../azalea-buf", version = "^0.1.0"}
|
||||
azalea-crypto = {path = "../azalea-crypto", version = "^0.1.0"}
|
||||
chrono = {version = "0.4.22", default-features = false}
|
||||
log = "0.4.17"
|
||||
num-bigint = "0.4.3"
|
||||
reqwest = { version = "0.11.12", features = ["json"] }
|
||||
serde = { version = "1.0.145", features = ["derive"] }
|
||||
reqwest = {version = "0.11.12", features = ["json"]}
|
||||
serde = {version = "1.0.145", features = ["derive"]}
|
||||
serde_json = "1.0.86"
|
||||
thiserror = "1.0.37"
|
||||
tokio = "1.21.2"
|
||||
uuid = "^1.1.2"
|
||||
azalea-crypto = { path = "../azalea-crypto", version = "^0.1.0" }
|
||||
thiserror = "1.0.37"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.21.2", features = ["full"] }
|
||||
tokio = {version = "1.21.2", features = ["full"]}
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
//! Handle Minecraft (Xbox) authentication.
|
||||
|
||||
|
||||
use crate::cache::{self, CachedAccount, ExpiringValue};
|
||||
use anyhow::anyhow;
|
||||
use serde::Deserialize;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::{collections::HashMap, time::Instant};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
time::{Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AuthOpts {
|
||||
|
@ -15,25 +20,65 @@ pub struct AuthOpts {
|
|||
// /// Whether we should get the Minecraft profile data (i.e. username, uuid,
|
||||
// /// skin, etc) for the player.
|
||||
// pub get_profile: bool,
|
||||
/// The directory to store the cache in. If this is not set, caching is not
|
||||
/// done.
|
||||
pub cache_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Authenticate with authenticate with Microsoft. If the data isn't cached,
|
||||
/// they'll be asked to go to log into Microsoft in a web page.
|
||||
pub async fn auth(opts: Option<AuthOpts>) -> anyhow::Result<AuthResult> {
|
||||
///
|
||||
/// The email is technically only used as a cache key, so it *could* be
|
||||
/// anything. You should just have it be the actual email so it's not confusing
|
||||
/// though, and in case the Microsoft API does start providing the real email.
|
||||
pub async fn auth(email: &str, opts: Option<AuthOpts>) -> anyhow::Result<AuthResult> {
|
||||
let opts = opts.unwrap_or_default();
|
||||
|
||||
let cached_account = if let Some(cache_file) = &opts.cache_file && let Some(account) = cache::get_account_in_cache(&cache_file, email).await {
|
||||
Some(account)
|
||||
} else { None };
|
||||
|
||||
// these two MUST be set by the end, since we return them in AuthResult
|
||||
let profile: ProfileResponse;
|
||||
let minecraft_access_token: String;
|
||||
|
||||
if let Some(account) = &cached_account && !account.mca.is_expired() {
|
||||
// the minecraft auth data is cached and not expired, so we can just
|
||||
// use that instead of doing auth all over again :)
|
||||
profile = account.profile.clone();
|
||||
minecraft_access_token = account.mca.data.access_token.clone();
|
||||
} else {
|
||||
let client = reqwest::Client::new();
|
||||
let mut msa = if let Some(account) = cached_account {
|
||||
account.msa
|
||||
} else {
|
||||
interactive_get_auth_token(&client).await?
|
||||
};
|
||||
if msa.is_expired() {
|
||||
todo!("refresh msa token");
|
||||
}
|
||||
let ms_access_token = &msa.data.access_token;
|
||||
println!("Got access token: {}", ms_access_token);
|
||||
|
||||
let auth_token_res = interactive_get_auth_token(&client).await?;
|
||||
// TODO: cache this
|
||||
println!("Got access token: {}", auth_token_res.access_token);
|
||||
let xbl_auth = auth_with_xbox_live(&client, &ms_access_token).await?;
|
||||
|
||||
let xbl_auth = auth_with_xbox_live(&client, &auth_token_res.access_token).await?;
|
||||
let xsts_token = obtain_xsts_for_minecraft(
|
||||
&client,
|
||||
&xbl_auth
|
||||
.get()
|
||||
.expect("Xbox Live auth token shouldn't have expired yet")
|
||||
.token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let xsts_token = obtain_xsts_for_minecraft(&client, &xbl_auth).await?;
|
||||
// Minecraft auth
|
||||
let mca = auth_with_minecraft(&client, &xbl_auth.data.user_hash, &xsts_token).await?;
|
||||
|
||||
let minecraft_access_token =
|
||||
auth_with_minecraft(&client, &xbl_auth.user_hash, &xsts_token).await?;
|
||||
minecraft_access_token = (&mca)
|
||||
.get()
|
||||
.expect("Minecraft auth shouldn't have expired yet")
|
||||
.access_token
|
||||
.to_string();
|
||||
|
||||
if opts.check_ownership {
|
||||
let has_game = check_ownership(&client, &minecraft_access_token).await?;
|
||||
|
@ -45,7 +90,23 @@ pub async fn auth(opts: Option<AuthOpts>) -> anyhow::Result<AuthResult> {
|
|||
}
|
||||
}
|
||||
|
||||
let profile = get_profile(&client, &minecraft_access_token).await?;
|
||||
profile = get_profile(&client, &minecraft_access_token).await?;
|
||||
|
||||
if let Some(cache_file) = opts.cache_file {
|
||||
cache::set_account_in_cache(
|
||||
&cache_file,
|
||||
email,
|
||||
CachedAccount {
|
||||
email: email.to_string(),
|
||||
mca,
|
||||
msa,
|
||||
xbl: xbl_auth,
|
||||
profile: profile.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AuthResult {
|
||||
access_token: minecraft_access_token,
|
||||
|
@ -69,7 +130,7 @@ pub struct DeviceCodeResponse {
|
|||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct AccessTokenResponse {
|
||||
token_type: String,
|
||||
expires_in: u64,
|
||||
|
@ -90,13 +151,14 @@ pub struct XboxLiveAuthResponse {
|
|||
}
|
||||
|
||||
/// Just the important data
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct XboxLiveAuth {
|
||||
token: String,
|
||||
user_hash: String,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct MinecraftAuthResponse {
|
||||
username: String,
|
||||
roles: Vec<String>,
|
||||
|
@ -120,7 +182,7 @@ pub struct GameOwnershipItem {
|
|||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ProfileResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
|
@ -131,7 +193,7 @@ pub struct ProfileResponse {
|
|||
/// Asks the user to go to a webpage and log in with Microsoft.
|
||||
async fn interactive_get_auth_token(
|
||||
client: &reqwest::Client,
|
||||
) -> anyhow::Result<AccessTokenResponse> {
|
||||
) -> anyhow::Result<ExpiringValue<AccessTokenResponse>> {
|
||||
// nintendo switch (real)
|
||||
let client_id = "00000000441cc96b";
|
||||
|
||||
|
@ -152,15 +214,13 @@ async fn interactive_get_auth_token(
|
|||
res.verification_uri, res.user_code
|
||||
);
|
||||
|
||||
let access_token_response: AccessTokenResponse;
|
||||
let login_expires_at = Instant::now() + std::time::Duration::from_secs(res.expires_in);
|
||||
|
||||
let expire_time = Instant::now() + std::time::Duration::from_secs(res.expires_in);
|
||||
|
||||
while Instant::now() < expire_time {
|
||||
while Instant::now() < login_expires_at {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(res.interval)).await;
|
||||
|
||||
println!("trying");
|
||||
if let Ok(res) = client
|
||||
if let Ok(access_token_response) = client
|
||||
.post(format!(
|
||||
"https://login.live.com/oauth20_token.srf?client_id={}",
|
||||
client_id
|
||||
|
@ -175,8 +235,15 @@ async fn interactive_get_auth_token(
|
|||
.json::<AccessTokenResponse>()
|
||||
.await
|
||||
{
|
||||
access_token_response = res;
|
||||
return Ok(access_token_response);
|
||||
let expires_at = SystemTime::now()
|
||||
+ std::time::Duration::from_secs(access_token_response.expires_in);
|
||||
return Ok(ExpiringValue {
|
||||
data: access_token_response,
|
||||
expires_at: expires_at
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -186,7 +253,7 @@ async fn interactive_get_auth_token(
|
|||
async fn auth_with_xbox_live(
|
||||
client: &reqwest::Client,
|
||||
access_token: &str,
|
||||
) -> anyhow::Result<XboxLiveAuth> {
|
||||
) -> anyhow::Result<ExpiringValue<XboxLiveAuth>> {
|
||||
let auth_json = json!({
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
|
@ -219,15 +286,28 @@ async fn auth_with_xbox_live(
|
|||
.await?;
|
||||
println!("got res: {:?}", res);
|
||||
|
||||
Ok(XboxLiveAuth {
|
||||
// not_after looks like 2020-12-21T19:52:08.4463796Z
|
||||
let expires_at = DateTime::parse_from_rfc3339(&res.not_after)
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"Failed to parse not_after date from Xbox Live: {}",
|
||||
res.not_after
|
||||
)
|
||||
})?
|
||||
.with_timezone(&Utc)
|
||||
.timestamp() as u64;
|
||||
Ok(ExpiringValue {
|
||||
data: XboxLiveAuth {
|
||||
token: res.token,
|
||||
user_hash: res.display_claims["xui"].get(0).unwrap()["uhs"].clone(),
|
||||
},
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn obtain_xsts_for_minecraft(
|
||||
client: &reqwest::Client,
|
||||
xbl_auth: &XboxLiveAuth,
|
||||
xbl_auth_token: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let res = client
|
||||
.post("https://xsts.auth.xboxlive.com/xsts/authorize")
|
||||
|
@ -235,7 +315,7 @@ async fn obtain_xsts_for_minecraft(
|
|||
.json(&json!({
|
||||
"Properties": {
|
||||
"SandboxId": "RETAIL",
|
||||
"UserTokens": [xbl_auth.token]
|
||||
"UserTokens": [xbl_auth_token.to_string()]
|
||||
},
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT"
|
||||
|
@ -253,7 +333,7 @@ async fn auth_with_minecraft(
|
|||
client: &reqwest::Client,
|
||||
user_hash: &str,
|
||||
xsts_token: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
) -> anyhow::Result<ExpiringValue<MinecraftAuthResponse>> {
|
||||
let res = client
|
||||
.post("https://api.minecraftservices.com/authentication/login_with_xbox")
|
||||
.header("Accept", "application/json")
|
||||
|
@ -266,7 +346,12 @@ async fn auth_with_minecraft(
|
|||
.await?;
|
||||
println!("{:?}", res);
|
||||
|
||||
Ok(res.access_token)
|
||||
let expires_at = SystemTime::now() + std::time::Duration::from_secs(res.expires_in);
|
||||
Ok(ExpiringValue {
|
||||
data: res,
|
||||
// to seconds since epoch
|
||||
expires_at: expires_at.duration_since(UNIX_EPOCH).unwrap().as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_ownership(
|
||||
|
|
|
@ -1,13 +1,109 @@
|
|||
//! Cache auth information
|
||||
|
||||
// pub fn get_auth_token() -> Option<AccessTokenResponse> {
|
||||
// let mut cache = CACHE.lock().unwrap();
|
||||
// if cache.auth_token.is_none() {
|
||||
// return None;
|
||||
// }
|
||||
// let auth_token = cache.auth_token.as_ref().unwrap();
|
||||
// if auth_token.expires_in < SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() {
|
||||
// return None;
|
||||
// }
|
||||
// Some(auth_token.clone())
|
||||
// }
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use thiserror::Error;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CacheError {
|
||||
#[error("Failed to read cache file")]
|
||||
ReadError(std::io::Error),
|
||||
#[error("Failed to write cache file")]
|
||||
WriteError(std::io::Error),
|
||||
#[error("Failed to parse cache file")]
|
||||
ParseError(serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct CachedAccount {
|
||||
pub email: String,
|
||||
/// Microsoft auth
|
||||
pub msa: ExpiringValue<crate::auth::AccessTokenResponse>,
|
||||
/// Xbox Live auth
|
||||
pub xbl: ExpiringValue<crate::auth::XboxLiveAuth>,
|
||||
/// Minecraft auth
|
||||
pub mca: ExpiringValue<crate::auth::MinecraftAuthResponse>,
|
||||
/// The user's Minecraft profile (i.e. username, UUID, skin)
|
||||
pub profile: crate::auth::ProfileResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct ExpiringValue<T> {
|
||||
/// Seconds since the UNIX epoch
|
||||
pub expires_at: u64,
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
impl<T> ExpiringValue<T> {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at
|
||||
< SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
/// Return the data if it's not expired, otherwise return `None`
|
||||
pub fn get(&self) -> Option<&T> {
|
||||
if self.is_expired() {
|
||||
None
|
||||
} else {
|
||||
Some(&self.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_entire_cache(cache_file: &Path) -> Result<Vec<CachedAccount>, CacheError> {
|
||||
let mut cache: Vec<CachedAccount> = Vec::new();
|
||||
if cache_file.exists() {
|
||||
let cache_file = File::open(cache_file)
|
||||
.await
|
||||
.map_err(CacheError::ReadError)?;
|
||||
// read the file into a string
|
||||
let mut cache_file = tokio::io::BufReader::new(cache_file);
|
||||
let mut contents = String::new();
|
||||
cache_file
|
||||
.read_to_string(&mut contents)
|
||||
.await
|
||||
.map_err(CacheError::ReadError)?;
|
||||
cache = serde_json::from_str(&contents).map_err(CacheError::ParseError)?;
|
||||
}
|
||||
Ok(cache)
|
||||
}
|
||||
async fn set_entire_cache(cache_file: &Path, cache: Vec<CachedAccount>) -> Result<(), CacheError> {
|
||||
let cache_file = File::create(cache_file)
|
||||
.await
|
||||
.map_err(CacheError::WriteError)?;
|
||||
let mut cache_file = tokio::io::BufWriter::new(cache_file);
|
||||
let cache = serde_json::to_string(&cache).map_err(CacheError::ParseError)?;
|
||||
cache_file
|
||||
.write_all(cache.as_bytes())
|
||||
.await
|
||||
.map_err(CacheError::WriteError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets cached data for the given email.
|
||||
///
|
||||
/// Technically it doesn't actually have to be an email since it's only the
|
||||
/// cache key. I considered using usernames or UUIDs as the cache key, but
|
||||
/// usernames change and no one has their UUID memorized.
|
||||
pub async fn get_account_in_cache(cache_file: &Path, email: &str) -> Option<CachedAccount> {
|
||||
let cache = get_entire_cache(cache_file).await.unwrap_or_default();
|
||||
cache.into_iter().find(|account| account.email == email)
|
||||
}
|
||||
|
||||
pub async fn set_account_in_cache(
|
||||
cache_file: &Path,
|
||||
email: &str,
|
||||
account: CachedAccount,
|
||||
) -> Result<(), CacheError> {
|
||||
let mut cache = get_entire_cache(cache_file).await.unwrap_or_default();
|
||||
cache.retain(|account| account.email != email);
|
||||
cache.push(account);
|
||||
set_entire_cache(cache_file, cache).await
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
#![feature(let_chains)]
|
||||
|
||||
pub mod auth;
|
||||
mod cache;
|
||||
pub mod game_profile;
|
||||
pub mod sessionserver;
|
||||
|
||||
|
@ -7,17 +10,6 @@ use serde::Deserialize;
|
|||
use serde_json::json;
|
||||
use std::{collections::HashMap, time::Instant};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AuthOpts {
|
||||
/// Whether we should check if the user actually owns the game. This will
|
||||
/// fail if the user has Xbox Game Pass! Note that this isn't really
|
||||
/// necessary, since getting the user profile will check this anyways.
|
||||
pub check_ownership: bool,
|
||||
// /// Whether we should get the Minecraft profile data (i.e. username, uuid,
|
||||
// /// skin, etc) for the player.
|
||||
// pub get_profile: bool,
|
||||
}
|
||||
|
||||
/// Ask the user to authenticate with Microsoft and return the user's Minecraft
|
||||
/// access token. There's caching, so if the user has already authenticated
|
||||
/// before then they won't have to log in manually again.
|
||||
|
@ -50,6 +42,29 @@ pub async fn auth(opts: AuthOpts) -> anyhow::Result<String> {
|
|||
Ok(minecraft_access_token)
|
||||
}
|
||||
|
||||
pub struct AuthOpts {
|
||||
/// Whether we should check if the user actually owns the game. This will
|
||||
/// fail if the user has Xbox Game Pass! Note that this isn't really
|
||||
/// necessary, since getting the user profile will check this anyways.
|
||||
pub check_ownership: bool,
|
||||
// /// Whether we should get the Minecraft profile data (i.e. username, uuid,
|
||||
// /// skin, etc) for the player.
|
||||
// pub get_profile: bool,
|
||||
/// The file to store cached data in. If this is `None`, then no
|
||||
/// caching will be done.
|
||||
pub cache_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for AuthOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_ownership: true,
|
||||
// get_profile: true,
|
||||
cache_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
user_code: String,
|
||||
|
@ -260,7 +275,7 @@ async fn auth_with_minecraft(
|
|||
Ok(res.access_token)
|
||||
}
|
||||
|
||||
async fn check_ownership(
|
||||
pub async fn check_ownership(
|
||||
client: &reqwest::Client,
|
||||
minecraft_access_token: &str,
|
||||
) -> anyhow::Result<bool> {
|
||||
|
@ -281,7 +296,7 @@ async fn check_ownership(
|
|||
Ok(!res.items.is_empty())
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
pub async fn get_profile(
|
||||
client: &reqwest::Client,
|
||||
minecraft_access_token: &str,
|
||||
) -> anyhow::Result<ProfileResponse> {
|
||||
|
|
Loading…
Add table
Reference in a new issue