Skip to main content

steam_auth/
validation.rs

1//! Pure validation functions for token and session handling.
2//!
3//! These functions are extracted from LoginSession to enable easier unit
4//! testing without requiring full session state.
5
6use steam_protos::{EAuthSessionGuardType, EAuthTokenPlatformType};
7use steamid::SteamID;
8
9use crate::{
10    error::SessionError,
11    helpers::decode_jwt,
12    types::{AllowedConfirmation, ValidAction},
13};
14
15/// Result of validating a refresh token.
16#[derive(Debug, Clone)]
17pub struct ValidatedRefreshToken {
18    /// The SteamID extracted from the token.
19    pub steam_id: SteamID,
20    /// The validated token string.
21    pub token: String,
22}
23
24/// Validate a refresh token for the given platform.
25///
26/// # Arguments
27/// * `token` - The JWT refresh token to validate
28/// * `platform_type` - The platform type to validate against
29///
30/// # Returns
31/// * `Ok(ValidatedRefreshToken)` if valid
32/// * `Err(SessionError)` if the token is invalid or doesn't match the platform
33pub fn validate_refresh_token(token: &str, platform_type: EAuthTokenPlatformType) -> Result<ValidatedRefreshToken, SessionError> {
34    let decoded = decode_jwt(token)?;
35
36    // Verify it's a refresh token (has "derive" audience)
37    if !decoded.aud.contains(&"derive".to_string()) {
38        return Err(SessionError::TokenError("Provided token is an access token, not a refresh token".into()));
39    }
40
41    // Verify platform audience
42    let required_audience = match platform_type {
43        EAuthTokenPlatformType::KEAuthTokenPlatformTypeSteamClient => "client",
44        EAuthTokenPlatformType::KEAuthTokenPlatformTypeMobileApp => "mobile",
45        EAuthTokenPlatformType::KEAuthTokenPlatformTypeWebBrowser => "web",
46        _ => "unknown",
47    };
48
49    if !decoded.aud.contains(&required_audience.to_string()) {
50        return Err(SessionError::TokenError(format!("Token platform type mismatch (required audience \"{}\")", required_audience)));
51    }
52
53    // Parse SteamID
54    let steam_id64: u64 = decoded.sub.parse().map_err(|_| SessionError::TokenError("Invalid SteamID in token".into()))?;
55
56    Ok(ValidatedRefreshToken { steam_id: SteamID::from(steam_id64), token: token.to_string() })
57}
58
59/// Validate an access token.
60///
61/// # Arguments
62/// * `token` - The JWT access token to validate
63/// * `existing_steam_id` - Optional existing SteamID to verify against
64///
65/// # Returns
66/// * `Ok(String)` - The validated token string
67/// * `Err(SessionError)` if the token is invalid or SteamID doesn't match
68pub fn validate_access_token(token: &str, existing_steam_id: Option<&SteamID>) -> Result<String, SessionError> {
69    let decoded = decode_jwt(token)?;
70
71    // Verify it's NOT a refresh token
72    if decoded.aud.contains(&"derive".to_string()) {
73        return Err(SessionError::TokenError("Provided token is a refresh token, not an access token".into()));
74    }
75
76    // Verify SteamID matches if we have one
77    if let Some(existing) = existing_steam_id {
78        let steam_id64: u64 = decoded.sub.parse().map_err(|_| SessionError::TokenError("Invalid SteamID in token".into()))?;
79        if existing.steam_id64() != steam_id64 {
80            return Err(SessionError::TokenError("Token belongs to a different account".into()));
81        }
82    }
83
84    Ok(token.to_string())
85}
86
87/// Determine valid actions from allowed confirmations.
88///
89/// # Arguments
90/// * `confirmations` - List of allowed confirmations from the auth response
91///
92/// # Returns
93/// * `None` if no action is required (KEAuthSessionGuardTypeNone present)
94/// * `Some(Vec<ValidAction>)` with the valid actions to take
95pub fn determine_valid_actions(confirmations: &[AllowedConfirmation]) -> Option<Vec<ValidAction>> {
96    let mut valid_actions = Vec::new();
97
98    for confirmation in confirmations {
99        match confirmation.confirmation_type {
100            EAuthSessionGuardType::KEAuthSessionGuardTypeNone => {
101                // No guard required
102                return None;
103            }
104            EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode | EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceCode => {
105                valid_actions.push(ValidAction { guard_type: confirmation.confirmation_type, detail: confirmation.message.clone() });
106            }
107            EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceConfirmation | EAuthSessionGuardType::KEAuthSessionGuardTypeEmailConfirmation => {
108                valid_actions.push(ValidAction { guard_type: confirmation.confirmation_type, detail: None });
109            }
110            _ => {}
111        }
112    }
113
114    if valid_actions.is_empty() {
115        None
116    } else {
117        Some(valid_actions)
118    }
119}
120
121/// Generate a session ID from random bytes.
122///
123/// # Arguments
124/// * `random_bytes` - 24 bytes of random data
125///
126/// # Returns
127/// A 24-character hex session ID
128pub fn generate_session_id(random_bytes: &[u8]) -> String {
129    random_bytes.iter().take(24).map(|b| format!("{:x}", b % 16)).collect()
130}
131
132/// Result of processing allowed confirmations.
133///
134/// This struct is returned by [`process_confirmations`] to indicate what
135/// actions are required to complete authentication.
136#[derive(Debug, Clone)]
137pub struct ProcessConfirmationsResult {
138    /// Whether user action is required to complete authentication.
139    pub requires_action: bool,
140    /// List of valid actions the user can take (if action required).
141    pub valid_actions: Option<Vec<ValidAction>>,
142    /// Whether a pre-supplied Steam Guard code should be submitted.
143    pub should_submit_presupplied_code: bool,
144    /// QR code challenge URL (for QR login flows).
145    pub qr_challenge_url: Option<String>,
146}
147
148/// Process allowed confirmations and determine required actions.
149///
150/// This is a pure function extracted from
151/// `LoginSession::process_start_session_response` to enable easier unit testing
152/// without requiring full session state.
153///
154/// # Arguments
155/// * `confirmations` - List of allowed confirmations from the auth response
156/// * `challenge_url` - Optional QR code challenge URL
157/// * `has_presupplied_code` - Whether a Steam Guard code was pre-supplied
158///
159/// # Returns
160/// A [`ProcessConfirmationsResult`] indicating what actions are needed
161///
162/// # Example
163/// ```rust,ignore
164/// use steam_auth::validation::{process_confirmations, AllowedConfirmation};
165///
166/// let confirmations = vec![AllowedConfirmation {
167///     confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeNone,
168///     message: None,
169/// }];
170///
171/// let result = process_confirmations(&confirmations, None, false);
172/// assert!(!result.requires_action);
173/// ```
174pub fn process_confirmations(confirmations: &[AllowedConfirmation], challenge_url: Option<String>, has_presupplied_code: bool) -> ProcessConfirmationsResult {
175    let mut valid_actions = Vec::new();
176    let mut should_submit_presupplied_code = false;
177
178    for confirmation in confirmations {
179        match confirmation.confirmation_type {
180            EAuthSessionGuardType::KEAuthSessionGuardTypeNone => {
181                // No guard required, we can poll immediately
182                return ProcessConfirmationsResult { requires_action: false, valid_actions: None, should_submit_presupplied_code: false, qr_challenge_url: None };
183            }
184            EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode | EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceCode => {
185                // Check if we have a pre-supplied code
186                if has_presupplied_code {
187                    should_submit_presupplied_code = true;
188                }
189                valid_actions.push(ValidAction { guard_type: confirmation.confirmation_type, detail: confirmation.message.clone() });
190            }
191            EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceConfirmation | EAuthSessionGuardType::KEAuthSessionGuardTypeEmailConfirmation => {
192                valid_actions.push(ValidAction { guard_type: confirmation.confirmation_type, detail: None });
193            }
194            _ => {}
195        }
196    }
197
198    if valid_actions.is_empty() {
199        // No valid actions found - this is an error state
200        ProcessConfirmationsResult {
201            requires_action: true,
202            valid_actions: None,
203            should_submit_presupplied_code: false,
204            qr_challenge_url: challenge_url,
205        }
206    } else {
207        ProcessConfirmationsResult {
208            requires_action: !should_submit_presupplied_code,
209            valid_actions: Some(valid_actions),
210            should_submit_presupplied_code,
211            qr_challenge_url: challenge_url,
212        }
213    }
214}
215
216/// Determine which Steam Guard code type is required from confirmations.
217///
218/// This is a pure function that examines the allowed confirmations and
219/// determines if an email code or device (TOTP) code is needed.
220///
221/// # Arguments
222/// * `confirmations` - List of allowed confirmations from the auth response
223///
224/// # Returns
225/// * `Some(EAuthSessionGuardType)` - The code type required (email or device)
226/// * `None` - If no code is required
227///
228/// # Example
229/// ```rust,ignore
230/// use steam_auth::validation::determine_required_code_type;
231///
232/// let confirmations = vec![AllowedConfirmation {
233///     confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode,
234///     message: Some("test@example.com".to_string()),
235/// }];
236///
237/// let code_type = determine_required_code_type(&confirmations);
238/// assert_eq!(code_type, Some(EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode));
239/// ```
240pub fn determine_required_code_type(confirmations: &[AllowedConfirmation]) -> Option<EAuthSessionGuardType> {
241    let needs_email = confirmations.iter().any(|c| c.confirmation_type == EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode);
242    let needs_totp = confirmations.iter().any(|c| c.confirmation_type == EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceCode);
243
244    if needs_email {
245        Some(EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode)
246    } else if needs_totp {
247        Some(EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceCode)
248    } else {
249        None
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
256
257    use super::*;
258
259    #[derive(serde::Serialize)]
260    struct JwtPayload<'a> {
261        sub: &'a str,
262        aud: &'a [&'a str],
263    }
264
265    /// Create a fake JWT for testing.
266    fn make_test_jwt(sub: &str, audiences: &[&str]) -> String {
267        let header = URL_SAFE_NO_PAD.encode(r#"{"typ":"JWT","alg":"EdDSA"}"#);
268
269        let payload = JwtPayload { sub, aud: audiences };
270
271        // Use to_string directly on the struct, simpler than creating Value then
272        // to_string
273        let payload_json = serde_json::to_string(&payload).unwrap();
274        let payload_encoded = URL_SAFE_NO_PAD.encode(payload_json);
275        format!("{}.{}.fake_signature", header, payload_encoded)
276    }
277
278    #[test]
279    fn test_validate_refresh_token_valid() {
280        let token = make_test_jwt("76561198000000000", &["derive", "client"]);
281        let result = validate_refresh_token(&token, EAuthTokenPlatformType::KEAuthTokenPlatformTypeSteamClient);
282
283        assert!(result.is_ok());
284        let validated = result.unwrap();
285        assert_eq!(validated.steam_id.steam_id64(), 76561198000000000);
286    }
287
288    #[test]
289    fn test_validate_refresh_token_rejects_access_token() {
290        let token = make_test_jwt("76561198000000000", &["client"]); // No "derive"
291        let result = validate_refresh_token(&token, EAuthTokenPlatformType::KEAuthTokenPlatformTypeSteamClient);
292
293        assert!(result.is_err());
294        assert!(matches!(result.unwrap_err(), SessionError::TokenError(_)));
295    }
296
297    #[test]
298    fn test_validate_refresh_token_platform_mismatch() {
299        let token = make_test_jwt("76561198000000000", &["derive", "web"]);
300        let result = validate_refresh_token(
301            &token,
302            EAuthTokenPlatformType::KEAuthTokenPlatformTypeSteamClient, // Expects "client"
303        );
304
305        assert!(result.is_err());
306    }
307
308    #[test]
309    fn test_validate_access_token_valid() {
310        let token = make_test_jwt("76561198000000000", &["client"]);
311        let result = validate_access_token(&token, None);
312
313        assert!(result.is_ok());
314    }
315
316    #[test]
317    fn test_validate_access_token_rejects_refresh_token() {
318        let token = make_test_jwt("76561198000000000", &["derive", "client"]);
319        let result = validate_access_token(&token, None);
320
321        assert!(result.is_err());
322    }
323
324    #[test]
325    fn test_validate_access_token_steam_id_mismatch() {
326        let token = make_test_jwt("76561198000000001", &["client"]);
327        let existing = SteamID::from(76561198000000000u64);
328        let result = validate_access_token(&token, Some(&existing));
329
330        assert!(result.is_err());
331    }
332
333    #[test]
334    fn test_validate_access_token_steam_id_matches() {
335        let token = make_test_jwt("76561198000000000", &["client"]);
336        let existing = SteamID::from(76561198000000000u64);
337        let result = validate_access_token(&token, Some(&existing));
338
339        assert!(result.is_ok());
340    }
341
342    #[test]
343    fn test_determine_valid_actions_none_guard() {
344        let confirmations = vec![AllowedConfirmation { confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeNone, message: None }];
345
346        let result = determine_valid_actions(&confirmations);
347        assert!(result.is_none());
348    }
349
350    #[test]
351    fn test_determine_valid_actions_email_code() {
352        let confirmations = vec![AllowedConfirmation {
353            confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode,
354            message: Some("test@example.com".to_string()),
355        }];
356
357        let result = determine_valid_actions(&confirmations);
358        assert!(result.is_some());
359        let actions = result.unwrap();
360        assert_eq!(actions.len(), 1);
361        assert_eq!(actions[0].guard_type, EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode);
362    }
363
364    #[test]
365    fn test_generate_session_id() {
366        let bytes: Vec<u8> = (0..24).collect();
367        let session_id = generate_session_id(&bytes);
368
369        assert_eq!(session_id.len(), 24);
370        // First 16 bytes become 0-f
371        assert_eq!(&session_id[..16], "0123456789abcdef");
372    }
373
374    #[test]
375    fn test_generate_session_id_deterministic() {
376        let bytes = vec![0x0a, 0x0b, 0x0c, 0x0d];
377        let id1 = generate_session_id(&bytes);
378        let id2 = generate_session_id(&bytes);
379
380        assert_eq!(id1, id2);
381    }
382
383    // Tests for process_confirmations
384
385    #[test]
386    fn test_process_confirmations_no_guard_required() {
387        let confirmations = vec![AllowedConfirmation { confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeNone, message: None }];
388
389        let result = process_confirmations(&confirmations, None, false);
390
391        assert!(!result.requires_action);
392        assert!(result.valid_actions.is_none());
393        assert!(!result.should_submit_presupplied_code);
394        assert!(result.qr_challenge_url.is_none());
395    }
396
397    #[test]
398    fn test_process_confirmations_email_code_required() {
399        let confirmations = vec![AllowedConfirmation {
400            confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode,
401            message: Some("t***@example.com".to_string()),
402        }];
403
404        let result = process_confirmations(&confirmations, None, false);
405
406        assert!(result.requires_action);
407        assert!(result.valid_actions.is_some());
408        let actions = result.valid_actions.unwrap();
409        assert_eq!(actions.len(), 1);
410        assert_eq!(actions[0].guard_type, EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode);
411        assert_eq!(actions[0].detail, Some("t***@example.com".to_string()));
412        assert!(!result.should_submit_presupplied_code);
413    }
414
415    #[test]
416    fn test_process_confirmations_with_presupplied_code() {
417        let confirmations = vec![AllowedConfirmation { confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceCode, message: None }];
418
419        let result = process_confirmations(&confirmations, None, true);
420
421        // When code is presupplied, action is not required (we'll auto-submit)
422        assert!(!result.requires_action);
423        assert!(result.valid_actions.is_some());
424        assert!(result.should_submit_presupplied_code);
425    }
426
427    #[test]
428    fn test_process_confirmations_device_confirmation() {
429        let confirmations = vec![AllowedConfirmation { confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceConfirmation, message: None }];
430
431        let result = process_confirmations(&confirmations, Some("https://qr.example.com".to_string()), false);
432
433        assert!(result.requires_action);
434        let actions = result.valid_actions.unwrap();
435        assert_eq!(actions.len(), 1);
436        assert_eq!(actions[0].guard_type, EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceConfirmation);
437        assert_eq!(result.qr_challenge_url, Some("https://qr.example.com".to_string()));
438    }
439
440    #[test]
441    fn test_process_confirmations_multiple_options() {
442        let confirmations = vec![
443            AllowedConfirmation {
444                confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode,
445                message: Some("t***@example.com".to_string()),
446            },
447            AllowedConfirmation { confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceCode, message: None },
448        ];
449
450        let result = process_confirmations(&confirmations, None, false);
451
452        assert!(result.requires_action);
453        let actions = result.valid_actions.unwrap();
454        assert_eq!(actions.len(), 2);
455    }
456
457    #[test]
458    fn test_process_confirmations_empty_list() {
459        let confirmations: Vec<AllowedConfirmation> = vec![];
460
461        let result = process_confirmations(&confirmations, None, false);
462
463        // Empty list means error state
464        assert!(result.requires_action);
465        assert!(result.valid_actions.is_none());
466    }
467
468    // Tests for determine_required_code_type
469
470    #[test]
471    fn test_determine_required_code_type_email() {
472        let confirmations = vec![AllowedConfirmation {
473            confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode,
474            message: Some("t***@example.com".to_string()),
475        }];
476
477        let result = determine_required_code_type(&confirmations);
478        assert_eq!(result, Some(EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode));
479    }
480
481    #[test]
482    fn test_determine_required_code_type_totp() {
483        let confirmations = vec![AllowedConfirmation { confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceCode, message: None }];
484
485        let result = determine_required_code_type(&confirmations);
486        assert_eq!(result, Some(EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceCode));
487    }
488
489    #[test]
490    fn test_determine_required_code_type_email_priority() {
491        // When both email and TOTP are available, email takes priority
492        let confirmations = vec![
493            AllowedConfirmation { confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceCode, message: None },
494            AllowedConfirmation {
495                confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode,
496                message: Some("t***@example.com".to_string()),
497            },
498        ];
499
500        let result = determine_required_code_type(&confirmations);
501        assert_eq!(result, Some(EAuthSessionGuardType::KEAuthSessionGuardTypeEmailCode));
502    }
503
504    #[test]
505    fn test_determine_required_code_type_none() {
506        let confirmations = vec![AllowedConfirmation { confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeNone, message: None }];
507
508        let result = determine_required_code_type(&confirmations);
509        assert!(result.is_none());
510    }
511
512    #[test]
513    fn test_determine_required_code_type_device_confirmation_not_code() {
514        // Device confirmation (push notification) is different from device code (TOTP)
515        let confirmations = vec![AllowedConfirmation { confirmation_type: EAuthSessionGuardType::KEAuthSessionGuardTypeDeviceConfirmation, message: None }];
516
517        let result = determine_required_code_type(&confirmations);
518        assert!(result.is_none());
519    }
520}