feat: implement Google OAuth support for Android and iOS
- Added support for Google OAuth with separate client IDs for Android and iOS. - Updated `verify_google_id_token` to validate `aud` against both client IDs and check `email_verified`. - Modified `google_oauth_handler` to accept and process the new client IDs. - Enhanced security by enforcing explicit JWT algorithm validation. - Updated mobile app to handle Google OAuth flow using `expo-auth-session`. - Fixed API request to send `id_token` in snake_case as expected by the backend. - Added necessary environment variables for Google client IDs in mobile app. - Implemented intent filter for Google OAuth redirect in AndroidManifest.xml.
This commit is contained in:
@@ -487,6 +487,84 @@ GET /api/v1/filaments/:id/label.svg?width_mm=22&height_mm=14&fields=color,name,q
|
||||
|
||||
---
|
||||
|
||||
## Mudanças Recentes (19/03/2026) — segunda entrada
|
||||
|
||||
### ✅ Google OAuth — suporte a client IDs Android e iOS
|
||||
|
||||
**Arquivos modificados**: `src/config.rs`, `src/adapters/inbound/auth_handler.rs`
|
||||
|
||||
**Problema**: o backend lia apenas `GOOGLE_CLIENT_ID` (Android). Tokens emitidos pelo fluxo iOS tinham `aud` diferente e eram rejeitados.
|
||||
|
||||
**Mudanças**:
|
||||
|
||||
- `Config` ganhou campo `google_client_id_ios: String` lido de `GOOGLE_CLIENT_ID_APPLE`
|
||||
- `verify_google_id_token` aceita agora dois client IDs e valida `aud` contra ambos:
|
||||
|
||||
```rust
|
||||
let valid_ids = [client_id_android, client_id_ios];
|
||||
let audience_valid = valid_ids.iter().any(|id| !id.is_empty() && *id == aud);
|
||||
if !audience_valid { return Err(AppError::Unauthorized); }
|
||||
```
|
||||
|
||||
- Assinatura atualizada: `verify_google_id_token(id_token, client_id_android, client_id_ios)`
|
||||
- Call site em `google_oauth_handler` passa `state.config.google_client_id` e `state.config.google_client_id_ios`
|
||||
|
||||
**Variável de ambiente adicionada**:
|
||||
|
||||
| Variável | Valor no .env |
|
||||
| ---------------------- | ------------------------------------------------------------------ |
|
||||
| `GOOGLE_CLIENT_ID` | `724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_ID_APPLE` | `724520558909-v3kubsvmf3vda7fep8qaabenmdap53hs.apps.googleusercontent.com` |
|
||||
|
||||
---
|
||||
|
||||
## Mudanças Recentes (19/03/2026)
|
||||
|
||||
### ✅ Hardening de segurança — Google OAuth e JWT
|
||||
|
||||
**Arquivos modificados**: `src/adapters/inbound/auth_handler.rs`, `src/application/auth_service.rs`
|
||||
|
||||
#### Google OAuth — validação de `aud` e `email_verified`
|
||||
|
||||
**Problema**: a função `verify_google_id_token` recebia `_client_id` como parâmetro mas nunca o usava. Qualquer portador de um token Google válido — emitido para qualquer app — podia autenticar no MeowSpool.
|
||||
|
||||
**Correção** (`auth_handler.rs`):
|
||||
|
||||
```rust
|
||||
// Valida audience: o token deve ter sido emitido para este app
|
||||
let aud = payload["aud"].as_str().ok_or(AppError::Unauthorized)?;
|
||||
if !client_id.is_empty() && aud != client_id {
|
||||
tracing::warn!(aud, client_id, "Google id_token audience mismatch");
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
// Rejeita contas Google com e-mail não verificado
|
||||
let email_verified = payload["email_verified"].as_str().unwrap_or("false");
|
||||
if email_verified != "true" {
|
||||
tracing::warn!("Google account email not verified");
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
```
|
||||
|
||||
- Parâmetro renomeado de `_client_id` para `client_id`
|
||||
- Rejeições geram `tracing::warn!` para auditoria
|
||||
|
||||
#### JWT — algoritmo explícito
|
||||
|
||||
**Problema**: `Validation::default()` não fixava o algoritmo permitido, abrindo margem para ataques com algoritmos inesperados.
|
||||
|
||||
**Correção** (`auth_service.rs`): substituído `Validation::default()` por `Validation::new(Algorithm::HS256)` em ambos os pontos de decodificação:
|
||||
|
||||
```rust
|
||||
// validate_access_token e decode_refresh_token:
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
decode::<Claims>(token, &key, &validation)
|
||||
```
|
||||
|
||||
`Algorithm` importado de `jsonwebtoken`.
|
||||
|
||||
---
|
||||
|
||||
## Mudanças Recentes (14/03/2026)
|
||||
|
||||
### ✅ Reenvio de e-mail de verificação
|
||||
|
||||
@@ -111,7 +111,11 @@ pub async fn google_oauth_handler(
|
||||
Json(req): Json<GoogleOAuthRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// Verificar id_token com a API do Google e extrair sub + email
|
||||
let (google_id, email) = verify_google_id_token(&req.id_token, &state.config.google_client_id).await?;
|
||||
let (google_id, email) = verify_google_id_token(
|
||||
&req.id_token,
|
||||
&state.config.google_client_id,
|
||||
&state.config.google_client_id_ios,
|
||||
).await?;
|
||||
let tokens = state.auth_service.google_oauth(google_id, email).await?;
|
||||
Ok(Json(AuthResponse {
|
||||
access_token: tokens.access_token,
|
||||
@@ -203,9 +207,15 @@ pub async fn reset_password_redirect_handler(
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Verifica o id_token do Google chamando o endpoint tokeninfo e retorna (sub, email).
|
||||
///
|
||||
/// Validações obrigatórias:
|
||||
/// - token reconhecido pelo Google (status 2xx)
|
||||
/// - `aud` bate com um dos client_ids registrados (Android ou iOS)
|
||||
/// - `email_verified` = true (previne emails não verificados no Google)
|
||||
async fn verify_google_id_token(
|
||||
id_token: &str,
|
||||
_client_id: &str,
|
||||
client_id_android: &str,
|
||||
client_id_ios: &str,
|
||||
) -> Result<(String, String), AppError> {
|
||||
let url = format!(
|
||||
"https://oauth2.googleapis.com/tokeninfo?id_token={id_token}"
|
||||
@@ -218,6 +228,7 @@ async fn verify_google_id_token(
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!("Google tokeninfo rejected id_token");
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
@@ -226,6 +237,23 @@ async fn verify_google_id_token(
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
|
||||
// Valida audience: aceita tokens emitidos para o client Android ou iOS do app.
|
||||
// Um token emitido para outro app (aud diferente) é rejeitado.
|
||||
let aud = payload["aud"].as_str().ok_or(AppError::Unauthorized)?;
|
||||
let valid_ids = [client_id_android, client_id_ios];
|
||||
let audience_valid = valid_ids.iter().any(|id| !id.is_empty() && *id == aud);
|
||||
if !audience_valid {
|
||||
tracing::warn!(aud, "Google id_token audience mismatch");
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
// Rejeita contas Google com e-mail não verificado
|
||||
let email_verified = payload["email_verified"].as_str().unwrap_or("false");
|
||||
if email_verified != "true" {
|
||||
tracing::warn!("Google account email not verified");
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let sub = payload["sub"]
|
||||
.as_str()
|
||||
.ok_or(AppError::Unauthorized)?
|
||||
|
||||
@@ -4,7 +4,7 @@ use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
@@ -137,10 +137,11 @@ impl AuthService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pub fn validate_access_token(&self, token: &str) -> Result<AccessClaims, AppError> {
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
decode::<AccessClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.config.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
&validation,
|
||||
)
|
||||
.map(|data| data.claims)
|
||||
.map_err(|_| AppError::Unauthorized)
|
||||
@@ -180,10 +181,11 @@ impl AuthService {
|
||||
}
|
||||
|
||||
fn decode_refresh_token(&self, token: &str) -> Result<RefreshClaims, AppError> {
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
decode::<RefreshClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.config.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
&validation,
|
||||
)
|
||||
.map(|data| data.claims)
|
||||
.map_err(|_| AppError::Unauthorized)
|
||||
|
||||
@@ -7,7 +7,8 @@ pub struct Config {
|
||||
pub jwt_secret: String,
|
||||
pub jwt_expiry_secs: u64,
|
||||
pub jwt_refresh_expiry_secs: u64,
|
||||
pub google_client_id: String,
|
||||
pub google_client_id: String, // Android OAuth 2.0 client ID
|
||||
pub google_client_id_ios: String, // iOS OAuth 2.0 client ID (GOOGLE_CLIENT_ID_APPLE)
|
||||
pub google_client_secret: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
@@ -36,6 +37,7 @@ impl Config {
|
||||
jwt_expiry_secs: optional("JWT_EXPIRY_SECS", 3600)?,
|
||||
jwt_refresh_expiry_secs: optional("JWT_REFRESH_EXPIRY_SECS", 2_592_000)?,
|
||||
google_client_id: optional_string("GOOGLE_CLIENT_ID"),
|
||||
google_client_id_ios: optional_string("GOOGLE_CLIENT_ID_APPLE"),
|
||||
google_client_secret: optional_string("GOOGLE_CLIENT_SECRET"),
|
||||
host: optional_string_default("HOST", "0.0.0.0"),
|
||||
port: optional("PORT", 8080)?,
|
||||
|
||||
Reference in New Issue
Block a user