Skip to main content

game_manager_lib/utils/oauth/
config.rs

1//! Configuração genérica de provedores OAuth2 e funções de troca/renovação de token.
2//!
3//! Cada plataforma (GOG, Battle.net, EA, Amazon Games) fornece um `OAuthProviderConfig`
4//! e reaproveita estas funções em vez de reimplementar o fluxo.
5
6use crate::errors::AppError;
7use crate::utils::http_client::HTTP_CLIENT;
8use crate::utils::oauth::core::PkceChallenge;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::time::{SystemTime, UNIX_EPOCH};
12use url::Url;
13
14// === ENUMS ===
15
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum TokenRequestMethod {
18    Post,
19    Get,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum TokenAuthMethod {
24    /// client_id/client_secret no corpo (form ou query) — GOG.
25    Body,
26    /// client_id:client_secret em Base64 no header `Authorization: Basic` — Epic.
27    BasicHeader,
28}
29
30// === STRUCTS ===
31
32/// Configuração de um provedor OAuth2.
33#[derive(Debug, Clone)]
34pub struct OAuthProviderConfig {
35    pub provider_id: &'static str,
36    pub client_id: String,
37    pub client_secret: Option<String>,
38    pub authorize_endpoint: String,
39    pub token_endpoint: String,
40    pub redirect_uri: String,
41    pub scopes: Vec<String>,
42    pub uses_pkce: bool,
43    pub extra_params: Vec<(String, String)>,
44    pub token_request_method: TokenRequestMethod,
45    pub token_auth_method: TokenAuthMethod,
46}
47
48/// Resposta bruta do endpoint de token do provedor.
49#[derive(Debug, Deserialize)]
50pub struct TokenResponse {
51    pub access_token: String,
52    pub refresh_token: Option<String>,
53    #[serde(default)]
54    pub expires_in: Option<u64>, // segundos
55    #[serde(default)]
56    pub token_type: Option<String>,
57    #[serde(default, deserialize_with = "deserialize_scope")]
58    pub scope: Option<String>,
59}
60
61/// Forma persistida do token (o que vai encriptado pro secrets.db).
62#[derive(Debug, Serialize, Deserialize, Clone)]
63pub struct OAuthToken {
64    pub access_token: String,
65    pub refresh_token: Option<String>,
66    pub expires_at: Option<i64>, // timestamp unix; None = expiração desconhecida
67    pub scope: Option<String>,
68    #[serde(default)]
69    pub extra: HashMap<String, String>, // ex: "device_serial" para Amazon
70}
71
72impl From<TokenResponse> for OAuthToken {
73    fn from(resp: TokenResponse) -> Self {
74        let expires_at = resp.expires_in.map(|secs| now_unix() + secs as i64);
75        Self {
76            access_token: resp.access_token,
77            refresh_token: resp.refresh_token,
78            expires_at,
79            scope: resp.scope,
80            extra: HashMap::new(),
81        }
82    }
83}
84
85impl OAuthToken {
86    /// Considera expirado 60s antes do prazo real, pra dar margem de segurança contra latência de rede durante uma chamada de API.
87    pub fn is_expired(&self) -> bool {
88        match self.expires_at {
89            Some(exp) => now_unix() >= exp - 60,
90            None => false,
91        }
92    }
93}
94
95// === HELPERS LOCAIS ===
96
97pub(crate) fn now_unix() -> i64 {
98    SystemTime::now()
99        .duration_since(UNIX_EPOCH)
100        .unwrap_or_default()
101        .as_secs() as i64
102}
103
104/// Alguns provedores (GOG) retornam `scope` como string; outros (Epic) retornam
105/// como array de strings, inclusive vazio (`[]`) quando não há escopos.
106/// Normaliza os dois formatos: array vira string com join por espaço, array vazio vira `None`.
107fn deserialize_scope<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
108where
109    D: serde::Deserializer<'de>,
110{
111    #[derive(Deserialize)]
112    #[serde(untagged)]
113    enum ScopeField {
114        Text(String),
115        List(Vec<String>),
116    }
117
118    let opt = Option::<ScopeField>::deserialize(deserializer)?;
119    Ok(match opt {
120        Some(ScopeField::Text(s)) => Some(s),
121        Some(ScopeField::List(list)) if list.is_empty() => None,
122        Some(ScopeField::List(list)) => Some(list.join(" ")),
123        None => None,
124    })
125}
126
127async fn parse_token_response(
128    response: reqwest::Response,
129    provider_id: &str,
130    stage: &str,
131) -> Result<TokenResponse, AppError> {
132    let status = response.status();
133    let body = response.text().await.unwrap_or_default();
134
135    if !status.is_success() {
136        return Err(AppError::OAuthTokenExchangeError(format!(
137            "[{provider_id}] falha em '{stage}': HTTP {status} - {body}"
138        )));
139    }
140
141    serde_json::from_str::<TokenResponse>(&body).map_err(|e| {
142        AppError::OAuthTokenExchangeError(format!(
143            "[{provider_id}] resposta inesperada em '{stage}': {e}"
144        ))
145    })
146}
147
148/// Monta a requisição do endpoint de token, aplicando client_id/secret no corpo
149/// ou via header `Authorization: Basic`, conforme `token_auth_method` do provedor.
150fn build_token_request(
151    config: &OAuthProviderConfig,
152    mut params: Vec<(&'static str, String)>,
153) -> reqwest::RequestBuilder {
154    let mut request = match config.token_request_method {
155        TokenRequestMethod::Get => HTTP_CLIENT.get(&config.token_endpoint),
156        TokenRequestMethod::Post => HTTP_CLIENT.post(&config.token_endpoint),
157    };
158
159    match config.token_auth_method {
160        TokenAuthMethod::Body => {
161            params.push(("client_id", config.client_id.clone()));
162            if let Some(secret) = &config.client_secret {
163                params.push(("client_secret", secret.clone()));
164            }
165        }
166        TokenAuthMethod::BasicHeader => {
167            let secret = config.client_secret.clone().unwrap_or_default();
168            request = request.basic_auth(&config.client_id, Some(secret));
169        }
170    }
171
172    match config.token_request_method {
173        TokenRequestMethod::Get => request.query(&params),
174        TokenRequestMethod::Post => request.form(&params),
175    }
176}
177
178// === FUNÇÕES ===
179
180/// Monta a URL de autorização para abrir no navegador do sistema.
181pub fn build_authorize_url(
182    config: &OAuthProviderConfig,
183    pkce: Option<&PkceChallenge>,
184    state: &str,
185) -> Result<Url, AppError> {
186    let mut url = Url::parse(&config.authorize_endpoint)
187        .map_err(|e| AppError::OAuthConfigError(format!("URL de autorização inválida: {e}")))?;
188
189    {
190        let mut query = url.query_pairs_mut();
191        query
192            .append_pair("client_id", &config.client_id)
193            .append_pair("redirect_uri", &config.redirect_uri)
194            .append_pair("response_type", "code")
195            .append_pair("state", state);
196
197        if !config.scopes.is_empty() {
198            query.append_pair("scope", &config.scopes.join(" "));
199        }
200
201        if config.uses_pkce {
202            let pkce = pkce.ok_or_else(|| {
203                AppError::OAuthConfigError(
204                    "uses_pkce=true mas nenhum PkceChallenge foi fornecido".into(),
205                )
206            })?;
207            query
208                .append_pair("code_challenge", &pkce.challenge)
209                .append_pair("code_challenge_method", "S256");
210        }
211
212        for (key, value) in &config.extra_params {
213            query.append_pair(key, value);
214        }
215    }
216
217    Ok(url)
218}
219
220/// Troca o `code` recebido no callback por um access_token.
221pub async fn exchange_code_for_token(
222    config: &OAuthProviderConfig,
223    code: &str,
224    pkce_verifier: Option<&str>,
225) -> Result<TokenResponse, AppError> {
226    let mut params = vec![
227        ("grant_type", "authorization_code".to_string()),
228        ("code", code.to_string()),
229        ("redirect_uri", config.redirect_uri.clone()),
230    ];
231    if let Some(verifier) = pkce_verifier {
232        params.push(("code_verifier", verifier.to_string()));
233    }
234
235    let response = build_token_request(config, params).send().await?;
236    parse_token_response(response, config.provider_id, "exchange").await
237}
238
239/// Renova o access_token usando o refresh_token salvo.
240pub async fn refresh_access_token(
241    config: &OAuthProviderConfig,
242    refresh_token: &str,
243) -> Result<TokenResponse, AppError> {
244    let params = vec![
245        ("grant_type", "refresh_token".to_string()),
246        ("refresh_token", refresh_token.to_string()),
247    ];
248
249    let response = build_token_request(config, params).send().await?;
250    parse_token_response(response, config.provider_id, "refresh").await
251}