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
+6 -2
View File
@@ -11,7 +11,9 @@
"resizeMode": "contain",
"backgroundColor": "#1E1B18"
},
"assetBundlePatterns": ["**/*"],
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.meowspool.app"
@@ -36,7 +38,9 @@
"cameraPermission": "O MeowSpool precisa da câmera para escanear QR Codes de filamentos."
}
],
"react-native-nfc-manager"
"react-native-nfc-manager",
"expo-web-browser",
"./plugins/withGoogleOAuth"
],
"experiments": {
"typedRoutes": true
+18 -3
View File
@@ -1,12 +1,13 @@
import React, { useState } from 'react';
import {
View, Text, TouchableOpacity, StyleSheet, Alert,
View, Text, TouchableOpacity, StyleSheet, Alert, Platform,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import * as IntentLauncher from 'expo-intent-launcher';
import { useFilamentStore } from '@store/filamentStore';
import { usePresetStore } from '@store/presetStore';
import { calcFilamentPercentage } from '@domain/Filament';
@@ -111,7 +112,14 @@ export default function LabelScreen(): React.ReactElement {
await FileSystem.writeAsStringAsync(fileUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
if (await Sharing.isAvailableAsync()) {
if (Platform.OS === 'android') {
const contentUri = await FileSystem.getContentUriAsync(fileUri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
type: 'application/pdf',
});
} else if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, { mimeType: 'application/pdf', UTI: 'com.adobe.pdf' });
} else {
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
@@ -123,7 +131,14 @@ export default function LabelScreen(): React.ReactElement {
);
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`;
await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' });
if (await Sharing.isAvailableAsync()) {
if (Platform.OS === 'android') {
const contentUri = await FileSystem.getContentUriAsync(fileUri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
type: 'image/svg+xml',
});
} else if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, { mimeType: 'image/svg+xml', UTI: 'public.svg-image' });
} else {
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
+40 -11
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import axios from 'axios';
import { Link, useRouter } from 'expo-router';
@@ -6,14 +6,19 @@ import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Ionicons } from '@expo/vector-icons';
import * as Google from 'expo-auth-session/providers/google';
import * as WebBrowser from 'expo-web-browser';
import { Screen } from '@presentation/components/layout/Screen';
import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
import { loginUseCase } from '@infrastructure/container';
import { loginUseCase, googleLoginUseCase } from '@infrastructure/container';
import { useAuthStore } from '@store/authStore';
import { CatIcon } from '@presentation/components/icons/CatIcon';
// Necessário para fechar o browser após redirect OAuth no Android/iOS
WebBrowser.maybeCompleteAuthSession();
const schema = z.object({
email: z.string().email('E-mail inválido'),
password: z.string().min(1, 'Senha obrigatória'),
@@ -31,6 +36,36 @@ export default function LoginScreen(): React.ReactElement {
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
// Hook do expo-auth-session para Google OAuth.
// Usa os client IDs de Android e iOS configurados via variáveis de ambiente.
const [_request, response, promptAsync] = Google.useAuthRequest({
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS,
});
// Reage ao resultado do fluxo OAuth assim que o browser fecha
useEffect(() => {
if (response?.type === 'success') {
const idToken = response.authentication?.idToken;
if (!idToken) {
Alert.alert('Erro', 'Não foi possível obter o token do Google.');
setIsGoogleLoading(false);
return;
}
googleLoginUseCase
.execute({ idToken })
.then((session) => setSession(session))
.then(() => router.replace('/(app)/(tabs)/home'))
.catch(() => Alert.alert('Erro', 'Não foi possível entrar com o Google.'))
.finally(() => setIsGoogleLoading(false));
} else if (response?.type === 'error') {
Alert.alert('Erro', 'Autenticação com Google cancelada ou falhou.');
setIsGoogleLoading(false);
} else if (response?.type === 'dismiss') {
setIsGoogleLoading(false);
}
}, [response]);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' },
@@ -57,16 +92,10 @@ export default function LoginScreen(): React.ReactElement {
}
}
async function onGoogleLogin(): Promise<void> {
function onGoogleLogin(): void {
setIsGoogleLoading(true);
try {
// TODO: Google Sign-In + GoogleLoginUseCase
router.replace('/(app)/(tabs)/home');
} catch {
Alert.alert('Erro', 'Não foi possível entrar com o Google.');
} finally {
setIsGoogleLoading(false);
}
// promptAsync abre o browser; o resultado chega via useEffect no `response`
promptAsync();
}
return (
+62
View File
@@ -15,10 +15,13 @@
"@react-navigation/native-stack": "^7.3.16",
"axios": "^1.7.7",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-intent-launcher": "^55.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
@@ -27,6 +30,7 @@
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.53.0",
@@ -6024,6 +6028,33 @@
"react-native": "*"
}
},
"node_modules/expo-auth-session": {
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz",
"integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==",
"license": "MIT",
"dependencies": {
"expo-application": "~7.0.8",
"expo-constants": "~18.0.11",
"expo-crypto": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-web-browser": "~15.0.10",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-auth-session/node_modules/expo-application": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-camera": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
@@ -6058,6 +6089,18 @@
"react-native": "*"
}
},
"node_modules/expo-crypto": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
"integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -6082,6 +6125,15 @@
"react-native": "*"
}
},
"node_modules/expo-intent-launcher": {
"version": "55.0.8",
"resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-55.0.8.tgz",
"integrity": "sha512-MrgQoHC+2AGMDfwg+X+zF/lp0N2tIa7zVtMmPJksyAvOUpgZXN8B5IZaW8JLGSnFhNCYF3eEzJkQiUzXcgX7CA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-keep-awake": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
@@ -6474,6 +6526,16 @@
}
}
},
"node_modules/expo-web-browser": {
"version": "15.0.10",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz",
"integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "54.0.23",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
+4
View File
@@ -18,10 +18,13 @@
"@react-navigation/native-stack": "^7.3.16",
"axios": "^1.7.7",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-intent-launcher": "^55.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
@@ -30,6 +33,7 @@
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.53.0",
+50
View File
@@ -0,0 +1,50 @@
/**
* Plugin local: injeta o intent filter do Google OAuth no AndroidManifest.xml
* durante o `expo prebuild`.
*
* Sem esse intent filter, o Android não sabe que o MeowSpool deve interceptar
* o redirect do Google OAuth (scheme com.googleusercontent.apps.*), fazendo o
* Chrome tratar a URL como pesquisa.
*
* O scheme é o reverse do Android OAuth 2.0 client ID registrado no Google Cloud Console.
*/
const { withAndroidManifest } = require('@expo/config-plugins');
const GOOGLE_REVERSE_CLIENT_ID =
'com.googleusercontent.apps.724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v';
module.exports = function withGoogleOAuth(config) {
return withAndroidManifest(config, (config) => {
const manifest = config.modResults;
const activities = manifest.manifest.application?.[0]?.activity ?? [];
const mainActivity = activities.find(
(a) => a.$?.['android:name'] === '.MainActivity',
);
if (!mainActivity) return config;
// Evita duplicar o intent filter em rebuilds consecutivos
const alreadyAdded = (mainActivity['intent-filter'] ?? []).some((filter) =>
(filter.data ?? []).some(
(d) => d.$?.['android:scheme'] === GOOGLE_REVERSE_CLIENT_ID,
),
);
if (alreadyAdded) return config;
mainActivity['intent-filter'] = [
...(mainActivity['intent-filter'] ?? []),
{
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
category: [
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
],
data: [{ $: { 'android:scheme': GOOGLE_REVERSE_CLIENT_ID } }],
},
];
return config;
});
};
@@ -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 });
}
},