Skip to main content

steam_user/services/
tokens.rs

1use base64::Engine;
2use hmac::{Hmac, Mac};
3use prost::Message;
4use sha2::Sha256;
5use steam_protos::{CAuthenticationRefreshTokenEnumerateResponse, CAuthenticationRefreshTokenRevokeRequest, EAuthTokenRevokeAction};
6use steam_totp::Secret;
7
8use crate::{client::SteamUser, endpoint::steam_endpoint, error::SteamUserError};
9
10impl SteamUser {
11    /// Lists all active authentication refresh tokens for the current account.
12    ///
13    /// Requires a mobile access token. These tokens represent active sessions
14    /// on various devices/browsers.
15    #[steam_endpoint(POST, host = Api, path = "/IAuthenticationService/EnumerateTokens/v1", kind = Auth)]
16    pub async fn enumerate_tokens(&self) -> Result<CAuthenticationRefreshTokenEnumerateResponse, SteamUserError> {
17        let access_token = self.session.access_token.as_ref().or(self.session.mobile_access_token.as_ref()).ok_or_else(|| SteamUserError::Other("Access token is required for enumerate_tokens".into()))?;
18
19        // Construct Protobuf request
20        let request = steam_protos::messages::auth::CAuthenticationRefreshTokenEnumerateRequest { include_revoked: Some(false) };
21
22        let mut body = Vec::new();
23        request.encode(&mut body).map_err(|e| SteamUserError::Other(e.to_string()))?;
24
25        tracing::debug!(token_len = access_token.len(), "using access_token");
26
27        // IAuthenticationService/EnumerateTokens rejects the access token when
28        // it is sent via the `Authorization: Bearer` header (HTTP 401, body:
29        // "verify your key= parameter"). It must be passed as an `access_token`
30        // query parameter. (RevokeRefreshToken behaves the same way;
31        // GenerateAccessTokenForApp, by contrast, does accept Bearer.)
32        let response = self.post_path("/IAuthenticationService/EnumerateTokens/v1").query(&[("access_token", access_token.as_str()), ("origin", "https://store.steampowered.com")]).form(&[("input_protobuf_encoded", base64::engine::general_purpose::STANDARD.encode(body))]).send().await?;
33
34        let status = response.status();
35        let bytes = response.bytes().await?;
36
37        if !status.is_success() {
38            tracing::error!("Failed to get tokens: HTTP {}", status);
39            return Err(SteamUserError::Other(format!("HTTP error {}", status)));
40        }
41
42        // Parse as protobuf
43        let result = CAuthenticationRefreshTokenEnumerateResponse::decode(bytes).map_err(|e| SteamUserError::Other(format!("Failed to decode protobuf: {}", e)))?;
44
45        Ok(result)
46    }
47
48    /// Checks if a refresh token with the specified token ID is currently
49    /// active.
50    // delegates to `enumerate_tokens` — no #[steam_endpoint]
51    #[tracing::instrument(skip(self))]
52    pub async fn check_token_exists(&self, token_id: &str) -> Result<bool, SteamUserError> {
53        let tokens = self.enumerate_tokens().await?;
54        let token_id_u64 = token_id.parse::<u64>().map_err(|_| SteamUserError::Other(format!("Invalid token ID format: {}", token_id)))?;
55
56        Ok(tokens.refresh_tokens.iter().any(|t| t.token_id == Some(token_id_u64)))
57    }
58
59    /// Revokes multiple authentication refresh tokens at once, effectively
60    /// logging out those sessions.
61    ///
62    /// Performs a single `enumerate_tokens` pre-check and post-check to
63    /// minimise network round-trips. Tokens that are already absent are
64    /// reported in `already_gone`; tokens that were sent for revocation but
65    /// still appear afterwards are reported in `failed`.
66    ///
67    /// # Arguments
68    ///
69    /// * `token_ids` - Slice of numeric token ID strings to revoke.
70    /// * `shared_secret` - The base64-encoded shared secret for generating the
71    ///   required HMAC signature.
72    #[steam_endpoint(POST, host = Api, path = "/IAuthenticationService/RevokeRefreshToken/v1", kind = Auth)]
73    pub async fn revoke_tokens(&self, token_ids: &[&str], shared_secret: Option<&str>) -> Result<RevokeTokensResult, SteamUserError> {
74        let access_token = self.session.mobile_access_token.as_ref().or(self.session.access_token.as_ref()).ok_or_else(|| SteamUserError::Other("Mobile access token is required for revoke_tokens".into()))?;
75        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
76        let base_mac = match shared_secret {
77            Some(s) => {
78                let secret = Secret::from_string(s).map_err(|e| SteamUserError::Other(e.to_string()))?;
79                let mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).map_err(|e| SteamUserError::Other(e.to_string()))?;
80                Some(mac)
81            }
82            None => None,
83        };
84
85        // Parse all token IDs upfront
86        let parsed: Vec<(&str, u64)> = token_ids
87            .iter()
88            .map(|id| {
89                let n = id.parse::<u64>().map_err(|_| SteamUserError::Other(format!("Invalid token ID format: {}", id)))?;
90                Ok((*id, n))
91            })
92            .collect::<Result<Vec<_>, SteamUserError>>()?;
93
94        // 1. Pre-check: fetch current active tokens
95        let initial_tokens = self.enumerate_tokens().await?;
96        let active_ids: std::collections::HashSet<u64> = initial_tokens.refresh_tokens.iter().filter_map(|t| t.token_id).collect();
97
98        let mut already_gone = Vec::new();
99        let mut to_revoke = Vec::new();
100
101        for &(id_str, id_u64) in &parsed {
102            if active_ids.contains(&id_u64) {
103                to_revoke.push((id_str, id_u64));
104            } else {
105                already_gone.push(id_str.to_string());
106            }
107        }
108
109        if to_revoke.is_empty() {
110            return Ok(RevokeTokensResult { success: vec![], failed: vec![], already_gone, response: initial_tokens });
111        }
112
113        // 2. Send revocation requests for each active token
114        for &(id_str, id_u64) in &to_revoke {
115            let signature = match &base_mac {
116                Some(mac) => {
117                    let mut m = mac.clone();
118                    // Sign the ASCII bytes of the token ID string (up to 20 chars).
119                    let token_id_ascii = id_str.as_bytes();
120                    let len = token_id_ascii.len().min(20);
121                    m.update(&token_id_ascii[..len]);
122                    Some(m.finalize().into_bytes().to_vec())
123                }
124                None => None,
125            };
126
127            let has_signature = signature.is_some();
128            let request = CAuthenticationRefreshTokenRevokeRequest {
129                token_id: Some(id_u64),
130                steamid: Some(steam_id.steam_id64()),
131                revoke_action: Some(EAuthTokenRevokeAction::Permanent.into()),
132                signature,
133            };
134
135            let mut body = Vec::new();
136            request.encode(&mut body).map_err(|e| SteamUserError::Other(e.to_string()))?;
137
138            // Same as enumerate_tokens: this endpoint requires the access token
139            // as an `access_token` query parameter, not an `Authorization:
140            // Bearer` header.
141            let response = match self.post_path("/IAuthenticationService/RevokeRefreshToken/v1").query(&[("access_token", access_token.as_str()), ("origin", "https://store.steampowered.com")]).form(&[("input_protobuf_encoded", base64::engine::general_purpose::STANDARD.encode(&body))]).send().await {
142                Ok(resp) => resp,
143                Err(e) => {
144                    tracing::error!("[RevokeTokens] HTTP request failed for token {}: {}", id_str, e);
145                    continue;
146                }
147            };
148
149            // Check x-eresult header first
150            let mut eresult = 1; // Default to OK
151            if let Some(er_val) = response.headers().get("x-eresult") {
152                if let Ok(er_str) = er_val.to_str() {
153                    if let Ok(er_num) = er_str.parse::<i32>() {
154                        eresult = er_num;
155                    }
156                }
157            }
158
159            if !response.status().is_success() || eresult != 1 {
160                let status = response.status();
161                tracing::error!("[RevokeTokens] Steam API rejected revocation for token {}: HTTP {} (EResult: {})", id_str, status, eresult);
162                // consume body to allow connection reuse
163                let _ = response.bytes().await;
164
165                if eresult == 5 && !has_signature {
166                    tracing::warn!("[RevokeTokens] Hint: Token {} might require a `shared_secret` to be revoked, but none was provided.", id_str);
167                } else if eresult == 15 && has_signature {
168                    tracing::warn!("[RevokeTokens] Hint: Token {} — EResult 15 (BadSignature). Signature was sent but Steam rejected it. The `shared_secret` stored in the DB is likely wrong or stale for this account.", id_str);
169                }
170
171                continue;
172            } else {
173                tracing::debug!("[RevokeTokens] Steam API accepted revocation request for token {}", id_str);
174                // consume body to allow connection reuse
175                let _ = response.bytes().await;
176            }
177        }
178
179        // 3. Post-check: verify which tokens were actually removed
180        let final_tokens = self.enumerate_tokens().await?;
181        let remaining_ids: std::collections::HashSet<u64> = final_tokens.refresh_tokens.iter().filter_map(|t| t.token_id).collect();
182
183        let mut success = Vec::new();
184        let mut failed = Vec::new();
185
186        for &(id_str, id_u64) in &to_revoke {
187            if remaining_ids.contains(&id_u64) {
188                failed.push(id_str.to_string());
189            } else {
190                success.push(id_str.to_string());
191            }
192        }
193
194        Ok(RevokeTokensResult { success, failed, already_gone, response: final_tokens })
195    }
196}
197
198/// Result of a batch revoke operation.
199#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
200pub struct RevokeTokensResult {
201    /// Token IDs that were successfully revoked (confirmed gone).
202    pub success: Vec<String>,
203    /// Token IDs that were sent for revocation but still exist on Steam.
204    pub failed: Vec<String>,
205    /// Token IDs that were already absent before the revocation request.
206    pub already_gone: Vec<String>,
207    /// The full token enumeration response from the post-revocation check.
208    #[serde(skip)]
209    pub response: steam_protos::CAuthenticationRefreshTokenEnumerateResponse,
210}