Skip to main content

steam_auth/
login_approver.rs

1//! Login approver for QR code authentication.
2//!
3//! This module provides the ability to approve login sessions from another
4//! device, typically used for approving QR code logins from the Steam mobile
5//! app.
6
7use hmac::{Hmac, Mac};
8use sha2::Sha256;
9use steam_protos::{CAuthenticationGetAuthSessionInfoResponse, EAuthTokenPlatformType, ESessionPersistence};
10use steamid::SteamID;
11
12use crate::{
13    auth_client::AuthenticationClient,
14    error::SessionError,
15    helpers::decode_jwt,
16    transport::{Transport, WebApiTransport},
17};
18
19type HmacSha256 = Hmac<Sha256>;
20
21/// Options for creating a LoginApprover.
22#[derive(Debug, Clone, Default)]
23pub struct ApproverOptions {
24    /// Machine ID for authentication.
25    pub machine_id: Option<Vec<u8>>,
26    /// Friendly name for the device.
27    pub device_friendly_name: Option<String>,
28}
29
30/// Login approver for approving/denying login sessions from another device.
31///
32/// This is typically used to approve QR code logins from the Steam mobile app.
33pub struct LoginApprover {
34    access_token: String,
35    shared_secret: Vec<u8>,
36    handler: AuthenticationClient,
37    steam_id: Option<SteamID>,
38}
39
40/// Builder for creating `LoginApprover` instances.
41pub struct LoginApproverBuilder {
42    access_token: String,
43    shared_secret: Vec<u8>,
44    options: ApproverOptions,
45    transport: Option<Transport>,
46    auth_client: Option<AuthenticationClient>,
47}
48
49impl LoginApproverBuilder {
50    /// Create a new builder for the specified access token and shared secret.
51    pub fn new(access_token: &str, shared_secret: impl AsRef<[u8]>) -> Self {
52        Self {
53            access_token: access_token.to_string(),
54            shared_secret: shared_secret.as_ref().to_vec(),
55            options: ApproverOptions::default(),
56            transport: None,
57            auth_client: None,
58        }
59    }
60
61    /// Set a custom transport for Steam API communication.
62    pub fn with_transport(mut self, transport: Transport) -> Self {
63        self.transport = Some(transport);
64        self
65    }
66
67    /// Set a custom authentication client.
68    pub fn with_auth_client(mut self, client: AuthenticationClient) -> Self {
69        self.auth_client = Some(client);
70        self
71    }
72
73    /// Set login approver options.
74    pub fn with_options(mut self, options: ApproverOptions) -> Self {
75        self.options = options;
76        self
77    }
78
79    /// Build the `LoginApprover`.
80    pub fn build(self) -> Result<LoginApprover, SessionError> {
81        let decoded = decode_jwt(&self.access_token)?;
82
83        // Parse SteamID from token
84        let steam_id64: u64 = decoded.sub.parse().map_err(|_| SessionError::TokenError("Invalid SteamID in token".into()))?;
85
86        let handler = if let Some(auth_client) = self.auth_client {
87            auth_client
88        } else {
89            let transport = self.transport.unwrap_or_else(|| Transport::WebApi(WebApiTransport::default()));
90            AuthenticationClient::new(transport, EAuthTokenPlatformType::KEAuthTokenPlatformTypeMobileApp, self.options.machine_id, self.options.device_friendly_name)
91        };
92
93        Ok(LoginApprover {
94            access_token: self.access_token,
95            shared_secret: self.shared_secret,
96            handler,
97            steam_id: Some(SteamID::from(steam_id64)),
98        })
99    }
100}
101
102impl LoginApprover {
103    /// Create a new login approver.
104    ///
105    /// # Arguments
106    ///
107    /// * `access_token` - A valid Steam access token with mobile app audience
108    /// * `shared_secret` - The shared secret from the Steam Guard mobile
109    ///   authenticator
110    /// * `options` - Optional configuration options
111    pub fn new(access_token: &str, shared_secret: impl AsRef<[u8]>, options: Option<ApproverOptions>) -> Result<Self, SessionError> {
112        LoginApproverBuilder::new(access_token, shared_secret).with_options(options.unwrap_or_default()).build()
113    }
114
115    /// Create a builder for customizing the login approver.
116    ///
117    /// Use this when you need to inject custom dependencies for testing.
118    pub fn builder(access_token: &str, shared_secret: impl AsRef<[u8]>) -> LoginApproverBuilder {
119        LoginApproverBuilder::new(access_token, shared_secret)
120    }
121
122    /// Get the SteamID from the access token.
123    pub fn steam_id(&self) -> Option<&SteamID> {
124        self.steam_id.as_ref()
125    }
126
127    /// Get information about an auth session from a QR challenge URL.
128    ///
129    /// # Arguments
130    ///
131    /// * `qr_challenge_url` - The QR challenge URL (e.g., "https://s.team/q/1/1234567890")
132    pub async fn get_auth_session_info(&self, qr_challenge_url: &str) -> Result<CAuthenticationGetAuthSessionInfoResponse, SessionError> {
133        let (client_id, version) = decode_qr_url(qr_challenge_url)?;
134
135        let mut response = self.handler.get_auth_session_info(&self.access_token, client_id).await?;
136
137        // Ensure version from URL is in the response if not already present
138        if response.version.is_none() {
139            response.version = Some(version);
140        }
141
142        Ok(response)
143    }
144
145    /// Approve or deny an auth session.
146    ///
147    /// # Arguments
148    ///
149    /// * `qr_challenge_url` - The QR challenge URL
150    /// * `approve` - `true` to approve, `false` to deny
151    /// * `persistence` - Session persistence level (defaults to Persistent)
152    pub async fn approve_auth_session(&self, qr_challenge_url: &str, approve: bool, persistence: Option<ESessionPersistence>) -> Result<(), SessionError> {
153        let (client_id, version) = decode_qr_url(qr_challenge_url)?;
154
155        let steam_id = self.steam_id.as_ref().ok_or(SessionError::InvalidState)?.steam_id64();
156
157        // Generate HMAC signature
158        let signature = self.create_signature(version, client_id, steam_id)?;
159
160        self.handler.submit_mobile_confirmation(&self.access_token, version, client_id, steam_id, &signature, approve, persistence.unwrap_or(ESessionPersistence::KESessionPersistencePersistent)).await
161    }
162
163    /// Create the HMAC-SHA256 signature for mobile confirmation.
164    fn create_signature(&self, version: i32, client_id: u64, steam_id: u64) -> Result<Vec<u8>, SessionError> {
165        let mut mac = HmacSha256::new_from_slice(&self.shared_secret).map_err(|e| SessionError::CryptoError(e.to_string()))?;
166
167        // Build the message to sign
168        let mut data = Vec::new();
169        data.extend_from_slice(&(version as u16).to_le_bytes());
170        data.extend_from_slice(&client_id.to_le_bytes());
171        data.extend_from_slice(&steam_id.to_le_bytes());
172
173        mac.update(&data);
174        let result = mac.finalize();
175
176        Ok(result.into_bytes().to_vec())
177    }
178}
179
180/// Decode a QR challenge URL into client_id and version.
181///
182/// Format: `https://s.team/q/{version}/{client_id}`
183fn decode_qr_url(url: &str) -> Result<(u64, i32), SessionError> {
184    // Parse URL like https://s.team/q/1/1234567890
185    let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect();
186
187    if parts.len() < 2 {
188        return Err(SessionError::InvalidQrUrl(url.to_string()));
189    }
190
191    // Get the last two segments
192    let client_id_str = parts.last().ok_or(SessionError::InvalidQrUrl(url.to_string()))?;
193    let version_str = parts.get(parts.len() - 2).ok_or(SessionError::InvalidQrUrl(url.to_string()))?;
194
195    let client_id: u64 = client_id_str.parse().map_err(|_| SessionError::InvalidQrUrl(url.to_string()))?;
196
197    let version: i32 = version_str.parse().map_err(|_| SessionError::InvalidQrUrl(url.to_string()))?;
198
199    Ok((client_id, version))
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_decode_qr_url() {
208        let url = "https://s.team/q/1/1234567890";
209        let (client_id, version) = decode_qr_url(url).unwrap();
210        assert_eq!(client_id, 1234567890);
211        assert_eq!(version, 1);
212    }
213
214    #[test]
215    fn test_decode_qr_url_with_trailing_slash() {
216        let url = "https://s.team/q/2/9876543210/";
217        let (client_id, version) = decode_qr_url(url).unwrap();
218        assert_eq!(client_id, 9876543210);
219        assert_eq!(version, 2);
220    }
221
222    #[test]
223    fn test_decode_qr_url_invalid() {
224        let url = "https://invalid.url/";
225        assert!(decode_qr_url(url).is_err());
226    }
227
228    #[test]
229    fn test_builder_rejects_invalid_token() {
230        // An invalid JWT should fail
231        let result = LoginApproverBuilder::new("invalid_token", b"secret").build();
232
233        assert!(result.is_err());
234    }
235}