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
+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 (