use hmac::{Hmac, Mac};
use sha2::Sha256;
use steam_protos::{CAuthenticationGetAuthSessionInfoResponse, EAuthTokenPlatformType, ESessionPersistence};
use steamid::SteamID;
use crate::{
auth_client::AuthenticationClient,
error::SessionError,
helpers::decode_jwt,
transport::{Transport, WebApiTransport},
};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Default)]
pub struct ApproverOptions {
pub machine_id: Option<Vec<u8>>,
pub device_friendly_name: Option<String>,
}
pub struct LoginApprover {
access_token: String,
shared_secret: Vec<u8>,
handler: AuthenticationClient,
steam_id: Option<SteamID>,
}
pub struct LoginApproverBuilder {
access_token: String,
shared_secret: Vec<u8>,
options: ApproverOptions,
transport: Option<Transport>,
auth_client: Option<AuthenticationClient>,
}
impl LoginApproverBuilder {
pub fn new(access_token: &str, shared_secret: impl AsRef<[u8]>) -> Self {
Self {
access_token: access_token.to_string(),
shared_secret: shared_secret.as_ref().to_vec(),
options: ApproverOptions::default(),
transport: None,
auth_client: None,
}
}
pub fn with_transport(mut self, transport: Transport) -> Self {
self.transport = Some(transport);
self
}
pub fn with_auth_client(mut self, client: AuthenticationClient) -> Self {
self.auth_client = Some(client);
self
}
pub fn with_options(mut self, options: ApproverOptions) -> Self {
self.options = options;
self
}
pub fn build(self) -> Result<LoginApprover, SessionError> {
let decoded = decode_jwt(&self.access_token)?;
let steam_id64: u64 = decoded.sub.parse().map_err(|_| SessionError::TokenError("Invalid SteamID in token".into()))?;
let handler = if let Some(auth_client) = self.auth_client {
auth_client
} else {
let transport = self.transport.unwrap_or_else(|| Transport::WebApi(WebApiTransport::default()));
AuthenticationClient::new(transport, EAuthTokenPlatformType::KEAuthTokenPlatformTypeMobileApp, self.options.machine_id, self.options.device_friendly_name)
};
Ok(LoginApprover {
access_token: self.access_token,
shared_secret: self.shared_secret,
handler,
steam_id: Some(SteamID::from(steam_id64)),
})
}
}
impl LoginApprover {
pub fn new(access_token: &str, shared_secret: impl AsRef<[u8]>, options: Option<ApproverOptions>) -> Result<Self, SessionError> {
LoginApproverBuilder::new(access_token, shared_secret).with_options(options.unwrap_or_default()).build()
}
pub fn builder(access_token: &str, shared_secret: impl AsRef<[u8]>) -> LoginApproverBuilder {
LoginApproverBuilder::new(access_token, shared_secret)
}
pub fn steam_id(&self) -> Option<&SteamID> {
self.steam_id.as_ref()
}
pub async fn get_auth_session_info(&self, qr_challenge_url: &str) -> Result<CAuthenticationGetAuthSessionInfoResponse, SessionError> {
let (client_id, version) = decode_qr_url(qr_challenge_url)?;
let mut response = self.handler.get_auth_session_info(&self.access_token, client_id).await?;
if response.version.is_none() {
response.version = Some(version);
}
Ok(response)
}
pub async fn approve_auth_session(&self, qr_challenge_url: &str, approve: bool, persistence: Option<ESessionPersistence>) -> Result<(), SessionError> {
let (client_id, version) = decode_qr_url(qr_challenge_url)?;
let steam_id = self.steam_id.as_ref().ok_or(SessionError::InvalidState)?.steam_id64();
let signature = self.create_signature(version, client_id, steam_id)?;
self.handler.submit_mobile_confirmation(&self.access_token, version, client_id, steam_id, &signature, approve, persistence.unwrap_or(ESessionPersistence::KESessionPersistencePersistent)).await
}
fn create_signature(&self, version: i32, client_id: u64, steam_id: u64) -> Result<Vec<u8>, SessionError> {
let mut mac = HmacSha256::new_from_slice(&self.shared_secret).map_err(|e| SessionError::CryptoError(e.to_string()))?;
let mut data = Vec::new();
data.extend_from_slice(&(version as u16).to_le_bytes());
data.extend_from_slice(&client_id.to_le_bytes());
data.extend_from_slice(&steam_id.to_le_bytes());
mac.update(&data);
let result = mac.finalize();
Ok(result.into_bytes().to_vec())
}
}
fn decode_qr_url(url: &str) -> Result<(u64, i32), SessionError> {
let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect();
if parts.len() < 2 {
return Err(SessionError::InvalidQrUrl(url.to_string()));
}
let client_id_str = parts.last().ok_or(SessionError::InvalidQrUrl(url.to_string()))?;
let version_str = parts.get(parts.len() - 2).ok_or(SessionError::InvalidQrUrl(url.to_string()))?;
let client_id: u64 = client_id_str.parse().map_err(|_| SessionError::InvalidQrUrl(url.to_string()))?;
let version: i32 = version_str.parse().map_err(|_| SessionError::InvalidQrUrl(url.to_string()))?;
Ok((client_id, version))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_qr_url() {
let url = "https://s.team/q/1/1234567890";
let (client_id, version) = decode_qr_url(url).unwrap();
assert_eq!(client_id, 1234567890);
assert_eq!(version, 1);
}
#[test]
fn test_decode_qr_url_with_trailing_slash() {
let url = "https://s.team/q/2/9876543210/";
let (client_id, version) = decode_qr_url(url).unwrap();
assert_eq!(client_id, 9876543210);
assert_eq!(version, 2);
}
#[test]
fn test_decode_qr_url_invalid() {
let url = "https://invalid.url/";
assert!(decode_qr_url(url).is_err());
}
#[test]
fn test_builder_rejects_invalid_token() {
let result = LoginApproverBuilder::new("invalid_token", b"secret").build();
assert!(result.is_err());
}
}