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:
2026-03-19 15:24:08 -03:00
parent 31c47fe69f
commit 34cbd4a861
16 changed files with 972 additions and 220 deletions
+78
View File
@@ -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
+30 -2
View File
@@ -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)?
+5 -3
View File
@@ -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)
+3 -1
View File
@@ -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)?,