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
+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)?,