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
+134 -4
View File
@@ -33,7 +33,8 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi
| Câmera / Scanner | `expo-camera` ~17 | ML Kit barcode scan; requer development build |
| NFC | `react-native-nfc-manager` | Leitura e gravação NDEF (NTAG215); requer build nativo |
| File system | `expo-file-system/legacy` | Salvar arquivos no cache; usar import `/legacy` |
| Compartilhamento | `expo-sharing` | Sheet nativo de compartilhamento de arquivos |
| Compartilhamento | `expo-sharing` | Sheet nativo de compartilhamento de arquivos (iOS) |
| Abrir arquivo | `expo-intent-launcher` | ACTION_VIEW no Android — abre apps de impressão (Niimbot) |
---
@@ -370,11 +371,139 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
- [ ] NFC suporte iOS (requer entitlement `com.apple.developer.nfc.readwrite`)
- [ ] Expo Notifications para alertas de estoque baixo
- [x] Deep link `verify-email?token=` com handler e integração ao backend
- [ ] Google OAuth com `expo-auth-session`
- [x] Google OAuth com `expo-auth-session` (Android + iOS, `Google.useAuthRequest` + `promptAsync`)
- [ ] Testes de integração com Jest + Testing Library
---
## Mudanças Recentes (19/03/2026) — segunda entrada
### ✅ Google OAuth — implementação completa
**Arquivos modificados**: `app/(auth)/login.tsx`, `src/infrastructure/container.ts`, `src/adapters/remote/ApiAuthRepository.ts`
**Pacotes instalados**: `expo-auth-session`, `expo-web-browser`
#### Fluxo implementado
```
Usuário toca "Entrar com Google"
→ promptAsync() abre browser via expo-web-browser
→ Google OAuth consent screen
→ redirect de volta ao app (scheme meowspool://)
→ useEffect detecta response.type === 'success'
→ authentication.idToken enviado para GoogleLoginUseCase
→ POST /auth/oauth/google { id_token }
→ backend valida aud, email_verified, cria/encontra usuário
→ retorna JWT pair → setSession → navega para home
```
#### Detalhes técnicos
**`login.tsx`** — hook `Google.useAuthRequest` + `useEffect` no `response`:
```tsx
WebBrowser.maybeCompleteAuthSession(); // fora do componente
const [_request, response, promptAsync] = Google.useAuthRequest({
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS,
});
useEffect(() => {
if (response?.type === 'success') {
const idToken = response.authentication?.idToken;
googleLoginUseCase.execute({ idToken })
.then(session => setSession(session))
.then(() => router.replace('/(app)/(tabs)/home'))
.finally(() => setIsGoogleLoading(false));
}
}, [response]);
```
- `onGoogleLogin` agora é síncrono e apenas chama `promptAsync()`
- Tratamento dos três estados: `success`, `error`, `dismiss`
**`ApiAuthRepository.ts`** — bug corrigido: o campo era enviado como `idToken` (camelCase) mas o backend espera `id_token` (snake_case):
```ts
// Antes (quebrado):
await httpClient.post('/auth/oauth/google', input); // enviava { idToken }
// Depois (correto):
await httpClient.post('/auth/oauth/google', { id_token: input.idToken });
```
**`container.ts`** — adicionado export de `googleLoginUseCase`:
```ts
import { GoogleLoginUseCase } from '@application/auth/AuthUseCases';
export const googleLoginUseCase = new GoogleLoginUseCase(authRepository);
```
#### Variáveis de ambiente necessárias (`mobile/.env`)
```
EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID=724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v.apps.googleusercontent.com
EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS=724520558909-v3kubsvmf3vda7fep8qaabenmdap53hs.apps.googleusercontent.com
```
#### Configuração no Google Cloud Console (obrigatória)
O redirect URI do `expo-auth-session` usa o scheme do app. Adicionar nos OAuth 2.0 Clients:
- **Android** (client `GOOGLE_CLIENT_ID_ANDROID`): SHA-1 fingerprint da keystore + package name `com.meowspool.app`
- **iOS** (client `GOOGLE_CLIENT_ID_IOS`): bundle ID `com.meowspool.app`
> **Nota**: o `expo-auth-session` com `Google.useAuthRequest` funciona em development build (não no Expo Go). Requer `mise exec -- npx expo run:android` após instalar os pacotes.
---
## Mudanças Recentes (19/03/2026)
### ✅ Hardening de segurança — bypass do interceptor de refresh
**Arquivo modificado**: `src/store/authStore.ts`
**Problema**: `loadStoredSession` usava `axios.get` diretamente com o access token lido do SecureStore, bypassando o interceptor de refresh do `httpClient`. Consequência: se o access token expirava, a sessão era destruída mesmo quando o refresh token ainda era válido, forçando login desnecessário.
**Correção**:
```ts
// Antes (bypass do interceptor):
const { data } = await axios.get(`${API_BASE_URL}/users/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
// Depois (usa httpClient → interceptor de refresh ativo):
const { data } = await httpClient.get('/users/me');
// Lê tokens após possível refresh (interceptor pode ter renovado o SecureStore)
const [accessToken, refreshToken] = await Promise.all([
SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN),
SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN),
]);
```
**Melhorias adicionais no `catch`**:
```ts
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);
}
set({ isLoading: false });
```
- Erros de rede (sem conexão) não forçam logout — tokens são preservados para quando a conectividade voltar
- Somente erros 401/403 limpam a sessão
- Import `API_BASE_URL` removido (não mais necessário)
- Import `httpClient` adicionado de `@adapters/remote/httpClient`
---
## Mudanças Recentes (14/03/2026)
### ✅ Reenvio de e-mail de verificação
@@ -606,8 +735,9 @@ mise exec -- npx expo run:android
- **Pacotes instalados**: `expo-file-system`, `expo-sharing`, `react-native-worklets`
- **Atenção**: importar `expo-file-system` como `expo-file-system/legacy` — a API padrão do SDK 54 não exporta `EncodingType` nem `writeAsStringAsync` diretamente
- **Fluxo PDF**: `GET /label.pdf` → resposta como `arraybuffer` → `btoa` para Base64 → salvar com `FileSystem.writeAsStringAsync` (encoding Base64) → `Sharing.shareAsync`
- **Fluxo SVG**: `GET /label.svg` → resposta como texto → salvar com encoding `'utf8'` → `Sharing.shareAsync`
- **Fluxo PDF**: `GET /label.pdf` → resposta como `arraybuffer` → `btoa` para Base64 → salvar com `FileSystem.writeAsStringAsync` (encoding Base64) → Android: `IntentLauncher.startActivityAsync('android.intent.action.VIEW', ...)` com `content://` URI via `FileSystem.getContentUriAsync` | iOS: `Sharing.shareAsync`
- **Fluxo SVG**: `GET /label.svg` → resposta como texto → salvar com encoding `'utf8'` → mesmo padrão Android/iOS acima
- **Por que ACTION_VIEW no Android**: `expo-sharing` usa `ACTION_SEND`, que não é declarado pelo Niimbot (e outros apps de impressão). Esses apps declaram apenas `ACTION_VIEW`. Usar `IntentLauncher` com `ACTION_VIEW` + `content://` URI mostra o mesmo seletor que o Gmail usa ao abrir um PDF.
- **Seletor de formato**: chips PDF/SVG no footer; padrão é PDF
- **Campos selecionáveis**: `enabledContent` (Set) é convertido para string CSV e enviado como `fields` query param — o backend respeita a seleção