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
@@ -17,7 +17,8 @@ export class ApiAuthRepository implements AuthRepository {
}
async loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession> {
const { data } = await httpClient.post('/auth/oauth/google', input);
// Backend espera snake_case: { id_token }
const { data } = await httpClient.post('/auth/oauth/google', { id_token: input.idToken });
return this.fetchSession(data.access_token as string, data.refresh_token as string);
}
+2
View File
@@ -7,6 +7,7 @@ import { ApiSpoolPresetRepository } from '@adapters/remote/ApiSpoolPresetReposit
import {
LoginUseCase,
RegisterUseCase,
GoogleLoginUseCase,
ForgotPasswordUseCase,
ResendVerificationUseCase,
ResetPasswordUseCase,
@@ -32,6 +33,7 @@ const presetRepository = new ApiSpoolPresetRepository();
// Use cases de autenticação
export const loginUseCase = new LoginUseCase(authRepository);
export const registerUseCase = new RegisterUseCase(authRepository);
export const googleLoginUseCase = new GoogleLoginUseCase(authRepository);
export const resendVerificationUseCase = new ResendVerificationUseCase(authRepository);
export const forgotPasswordUseCase = new ForgotPasswordUseCase(authRepository);
export const resetPasswordUseCase = new ResetPasswordUseCase(authRepository);
+32 -20
View File
@@ -2,7 +2,8 @@ import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
import axios from 'axios';
import type { User, AuthSession } from '@domain/User';
import { SECURE_STORE_ACCESS_TOKEN, SECURE_STORE_REFRESH_TOKEN, API_BASE_URL } from '@shared/constants';
import { SECURE_STORE_ACCESS_TOKEN, SECURE_STORE_REFRESH_TOKEN } from '@shared/constants';
import { httpClient } from '@adapters/remote/httpClient';
interface AuthState {
user: User | null;
@@ -56,27 +57,38 @@ export const useAuthStore = create<AuthState>((set) => ({
loadStoredSession: async () => {
set({ isLoading: true });
try {
const accessToken = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN);
const refreshToken = await SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN);
if (accessToken) {
const { data } = await axios.get(`${API_BASE_URL}/users/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const user: User = {
id: String(data.id),
email: data.email as string,
name: (data.name as string | null) ?? null,
googleId: (data.google_id as string | null) ?? null,
createdAt: data.created_at as string,
};
set({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false });
} else {
const storedToken = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN);
if (!storedToken) {
set({ isLoading: false });
return;
}
// Usa httpClient para que o interceptor de refresh atue automaticamente:
// se o access token expirou mas o refresh token é válido, a sessão é renovada
// em vez de forçar logout desnecessariamente.
const { data } = await httpClient.get('/users/me');
// Lê tokens após possível refresh (o interceptor pode ter atualizado o SecureStore)
const [accessToken, refreshToken] = await Promise.all([
SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN),
SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN),
]);
const user: User = {
id: String(data.id),
email: data.email as string,
name: (data.name as string | null) ?? null,
googleId: (data.google_id as string | null) ?? null,
createdAt: data.created_at as string,
};
set({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false });
} catch (err) {
// Limpa sessão apenas em falha de autenticação (401/403).
// Erros de rede (sem conexão) não forçam logout — o usuário pode estar offline.
const isAuthError =
axios.isAxiosError(err) &&
(err.response?.status === 401 || err.response?.status === 403);
if (isAuthError || !axios.isAxiosError(err)) {
await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN);
await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
}
} catch {
// Token inválido ou expirado — limpa a sessão
await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN);
await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
set({ isLoading: false });
}
},