feat(auth/ios): native Apple Sign-In via expo-apple-authentication

Vorher: stores/auth.ts hatte TODO + fiel auf Supabase-Web-OAuth-Flow zurück,
was fehlschlug mit 400 'Unsupported provider: missing OAuth client ID' weil
der Supabase-Apple-OAuth-Provider nicht konfiguriert ist.

Jetzt: native Flow ohne Supabase-Provider-Config —
- expo-apple-authentication.signInAsync() → identityToken
- supabase.auth.signInWithIdToken({provider:'apple', token}) verifiziert direkt
  gegen Apple's Public-Keys (kein Client-Secret-JWT-Setup nötig)
- User-Cancel (ERR_REQUEST_CANCELED) → leeres Resultat statt Error
- Platform-Guard: Apple-Path nur auf iOS

app.config.ts: ios.usesAppleSignIn=true → Expo prebuild generiert das
com.apple.developer.applesignin-Entitlement in die .entitlements. Beim
ersten EAS-Build wird die Capability auto-registriert im Apple-Developer-
Portal für org.rebreak.app.

signin.tsx + signup.tsx: Apple-Button conditional auf Platform.OS==='ios'
gerendert. Android-User sehen nur Google-Button (auf Android gibt es kein
natives Apple Sign-In).

App-Store-Submission-Pflicht (Apple Guideline 4.8 — wer OAuth-Login mit
3rd-Party-Provider anbietet, muss auch Apple Sign-In bieten).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-18 00:13:45 +02:00
parent 534f978b4e
commit 23cc147231
4 changed files with 75 additions and 34 deletions

View File

@ -21,6 +21,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
supportsTablet: true, supportsTablet: true,
bundleIdentifier: "org.rebreak.app", bundleIdentifier: "org.rebreak.app",
buildNumber: "10", buildNumber: "10",
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
usesAppleSignIn: true,
config: { config: {
usesNonExemptEncryption: false, usesNonExemptEncryption: false,
}, },

View File

@ -5,6 +5,7 @@ import {
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
Platform,
} from 'react-native'; } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
@ -268,20 +269,22 @@ export default function SignInScreen() {
<Text className="text-neutral-900 text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.googleSignin')}</Text> <Text className="text-neutral-900 text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.googleSignin')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity {Platform.OS === 'ios' ? (
onPress={() => onOAuth('apple')} <TouchableOpacity
disabled={isLoading} onPress={() => onOAuth('apple')}
activeOpacity={0.8} disabled={isLoading}
className="flex-row items-center justify-center gap-3 bg-neutral-900 rounded-xl mb-6 disabled:opacity-40" activeOpacity={0.8}
style={{ paddingVertical: 14 }} className="flex-row items-center justify-center gap-3 bg-neutral-900 rounded-xl mb-6 disabled:opacity-40"
> style={{ paddingVertical: 14 }}
{oauthLoading === 'apple' ? ( >
<ActivityIndicator color="white" size="small" /> {oauthLoading === 'apple' ? (
) : ( <ActivityIndicator color="white" size="small" />
<AppleIcon /> ) : (
)} <AppleIcon />
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.appleSignin')}</Text> )}
</TouchableOpacity> <Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.appleSignin')}</Text>
</TouchableOpacity>
) : null}
{/* Divider */} {/* Divider */}
<View className="flex-row items-center mb-6"> <View className="flex-row items-center mb-6">

View File

@ -6,6 +6,7 @@ import {
TouchableOpacity, TouchableOpacity,
Image, Image,
ActivityIndicator, ActivityIndicator,
Platform,
} from 'react-native'; } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
@ -132,20 +133,22 @@ export default function SignUpScreen() {
<Text className="text-neutral-900 text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.googleSignup')}</Text> <Text className="text-neutral-900 text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.googleSignup')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity {Platform.OS === 'ios' ? (
onPress={() => onOAuth('apple')} <TouchableOpacity
disabled={isLoading} onPress={() => onOAuth('apple')}
activeOpacity={0.8} disabled={isLoading}
className="flex-row items-center justify-center gap-3 bg-neutral-900 rounded-xl mb-6 disabled:opacity-40" activeOpacity={0.8}
style={{ paddingVertical: 14 }} className="flex-row items-center justify-center gap-3 bg-neutral-900 rounded-xl mb-6 disabled:opacity-40"
> style={{ paddingVertical: 14 }}
{oauthLoading === 'apple' ? ( >
<ActivityIndicator color="white" size="small" /> {oauthLoading === 'apple' ? (
) : ( <ActivityIndicator color="white" size="small" />
<AppleIcon /> ) : (
)} <AppleIcon />
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.appleSignup')}</Text> )}
</TouchableOpacity> <Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.appleSignup')}</Text>
</TouchableOpacity>
) : null}
{/* Divider */} {/* Divider */}
<View className="flex-row items-center mb-6"> <View className="flex-row items-center mb-6">

View File

@ -1,7 +1,9 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { Platform } from 'react-native';
import type { Session, User } from '@supabase/supabase-js'; import type { Session, User } from '@supabase/supabase-js';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import * as Linking from 'expo-linking'; import * as Linking from 'expo-linking';
import * as AppleAuthentication from 'expo-apple-authentication';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
@ -129,15 +131,44 @@ export const useAuthStore = create<AuthState>((set) => ({
}, },
signInWithOAuth: async (provider) => { signInWithOAuth: async (provider) => {
const redirectUri = Linking.createURL('auth/callback'); // Apple Sign-In native flow (iOS only) — KEIN Supabase-Apple-OAuth-Provider-
// Config nötig (Bundle-ID = Apple-Client-ID). expo-apple-authentication
// liefert identityToken → supabase.auth.signInWithIdToken verifiziert direkt
// gegen Apple's public keys. App-Store-Submit-Pflicht weil Google-Sign-In
// auch da ist (Apple Guideline 4.8).
if (provider === 'apple') { if (provider === 'apple') {
// TODO: configure Apple Sign-In if (Platform.OS !== 'ios') {
// Requires expo-apple-authentication to be installed + Apple Developer entitlement. return { error: 'Apple Sign-In ist nur auf iOS verfügbar.' };
// Apple Client ID = Bundle ID (org.rebreak.app) for native flow. }
// For now we fall through to the Supabase OAuth web flow as a temporary path. const available = await AppleAuthentication.isAvailableAsync();
if (!available) {
return { error: 'Apple Sign-In auf diesem Gerät nicht verfügbar.' };
}
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
return { error: 'Apple identityToken fehlt.' };
}
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: credential.identityToken,
});
if (error) return { error: error.message };
set({ session: data.session, user: data.user ?? null });
return {};
} catch (e: any) {
// User-Cancel ist kein Error — leere Antwort.
if (e?.code === 'ERR_REQUEST_CANCELED') return {};
return { error: e?.message ?? 'Apple Sign-In fehlgeschlagen.' };
}
} }
const redirectUri = Linking.createURL('auth/callback');
const { data, error } = await supabase.auth.signInWithOAuth({ const { data, error } = await supabase.auth.signInWithOAuth({
provider, provider,
options: { options: {