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:
+134
-4
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user