steam_auth/
login_approver.rs1use 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#[derive(Debug, Clone, Default)]
23pub struct ApproverOptions {
24 pub machine_id: Option<Vec<u8>>,
26 pub device_friendly_name: Option<String>,
28}
29
30pub struct LoginApprover {
34 access_token: String,
35 shared_secret: Vec<u8>,
36 handler: AuthenticationClient,
37 steam_id: Option<SteamID>,
38}
39
40pub 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 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 pub fn with_transport(mut self, transport: Transport) -> Self {
63 self.transport = Some(transport);
64 self
65 }
66
67 pub fn with_auth_client(mut self, client: AuthenticationClient) -> Self {
69 self.auth_client = Some(client);
70 self
71 }
72
73 pub fn with_options(mut self, options: ApproverOptions) -> Self {
75 self.options = options;
76 self
77 }
78
79 pub fn build(self) -> Result<LoginApprover, SessionError> {
81 let decoded = decode_jwt(&self.access_token)?;
82
83 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 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 pub fn builder(access_token: &str, shared_secret: impl AsRef<[u8]>) -> LoginApproverBuilder {
119 LoginApproverBuilder::new(access_token, shared_secret)
120 }
121
122 pub fn steam_id(&self) -> Option<&SteamID> {
124 self.steam_id.as_ref()
125 }
126
127 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 if response.version.is_none() {
139 response.version = Some(version);
140 }
141
142 Ok(response)
143 }
144
145 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 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 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 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
180fn decode_qr_url(url: &str) -> Result<(u64, i32), SessionError> {
184 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 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 let result = LoginApproverBuilder::new("invalid_token", b"secret").build();
232
233 assert!(result.is_err());
234 }
235}