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:
@@ -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