chahinebrini 23cc147231 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>
2026-05-18 00:13:45 +02:00

254 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { create } from 'zustand';
import { Platform } from 'react-native';
import type { Session, User } from '@supabase/supabase-js';
import * as WebBrowser from 'expo-web-browser';
import * as Linking from 'expo-linking';
import * as AppleAuthentication from 'expo-apple-authentication';
import { supabase } from '../lib/supabase';
import { apiFetch } from '../lib/api';
WebBrowser.maybeCompleteAuthSession();
export type DeviceLockedError = {
type: 'DEVICE_LOCKED';
lockedUntil: string | null;
releaseRequestable: boolean;
};
type SignInResult = { error?: string; deviceLocked?: DeviceLockedError };
type AuthState = {
user: User | null;
session: Session | null;
loading: boolean;
init: () => Promise<void>;
signInWithPassword: (email: string, password: string) => Promise<SignInResult>;
signUp: (
email: string,
password: string,
metadata: { username: string; firstName?: string; lastName?: string; avatarId: string; avatarUrl: string }
) => Promise<{ error?: string }>;
signOut: () => Promise<void>;
signInWithOAuth: (provider: 'google' | 'apple') => Promise<{ error?: string }>;
resetPasswordForEmail: (email: string) => Promise<{ error?: string }>;
verifyOtp: (email: string, token: string) => Promise<{ error?: string }>;
resendConfirmation: (email: string) => Promise<{ error?: string }>;
};
export const useAuthStore = create<AuthState>((set) => ({
user: null,
session: null,
loading: true,
init: async () => {
const { data } = await supabase.auth.getSession();
set({
session: data.session,
user: data.session?.user ?? null,
loading: false,
});
supabase.auth.onAuthStateChange((_event, session) => {
set({ session, user: session?.user ?? null });
});
},
signInWithPassword: async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
set({ session: data.session, user: data.user });
// After Supabase auth succeeds, check device binding via the register endpoint.
// If the device is already bound to a different account the backend returns 409
// DEVICE_LOCKED. We sign back out of Supabase in that case — the user cannot
// proceed on this device until the lock is released.
// TODO(backend): move this check into POST /api/devices/register so it fires
// automatically with x-device-id. Currently requires explicit call here.
try {
await apiFetch('/api/devices/check-lock', { method: 'POST' });
} catch (e: any) {
const msg: string = e?.message ?? '';
const status = parseInt(msg.match(/^API (\d+)/)?.[1] ?? '0', 10);
if (status === 409) {
try {
const raw = msg.replace(/^API \d+: /, '');
const parsed = JSON.parse(raw);
const lockData = parsed?.data ?? parsed;
if (lockData?.error === 'DEVICE_LOCKED') {
await supabase.auth.signOut();
set({ session: null, user: null });
return {
deviceLocked: {
type: 'DEVICE_LOCKED',
lockedUntil: lockData.lockedUntil ?? null,
releaseRequestable: lockData.releaseRequestable ?? true,
},
};
}
} catch {
// JSON parse failed — not a DEVICE_LOCKED response, ignore
}
}
// Non-409 errors from the check are non-fatal (device check is best-effort)
}
return {};
},
signUp: async (email, password, metadata) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
username: metadata.username,
first_name: metadata.firstName ?? '',
last_name: metadata.lastName ?? '',
avatar_id: metadata.avatarId,
avatar_url: metadata.avatarUrl,
},
// Deep-link redirect for email confirmation — scheme registered in app.config.ts
emailRedirectTo: 'rebreak://auth/confirm',
},
});
if (error) return { error: error.message };
set({ session: data.session, user: data.user ?? null });
return {};
},
signOut: async () => {
await supabase.auth.signOut();
// Google-OAuth-Cookie in SafariViewController/Chrome Custom Tabs leeren,
// sonst springt der nächste Sign-in stillschweigend auf den vorigen Account
// statt den Account-Picker zu zeigen.
try {
await WebBrowser.coolDownAsync();
} catch {
// ignore
}
set({ session: null, user: null });
},
signInWithOAuth: async (provider) => {
// 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 (Platform.OS !== 'ios') {
return { error: 'Apple Sign-In ist nur auf iOS verfügbar.' };
}
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({
provider,
options: {
redirectTo: redirectUri,
skipBrowserRedirect: true,
// Google: prompt=select_account erzwingt den Account-Picker,
// auch wenn der Browser noch eine aktive Google-Session hat.
...(provider === 'google' ? { queryParams: { prompt: 'select_account' } } : {}),
},
});
if (error) return { error: error.message };
if (!data.url) return { error: 'Kein OAuth-URL erhalten' };
// Cleanup eines evtl. noch offenen WebBrowser-Sessions aus einem vorherigen,
// abgebrochenen OAuth-Versuch — sonst wirft openAuthSessionAsync mit
// „Another web browser is already open". Idempotent, safe auch wenn nichts
// offen ist.
try {
await WebBrowser.dismissAuthSession();
} catch {
// ignore
}
const result = await WebBrowser.openAuthSessionAsync(data.url, redirectUri);
if (result.type !== 'success') {
return result.type === 'cancel' ? {} : { error: 'OAuth fehlgeschlagen' };
}
// Extract tokens from the deep-link URL fragment
const url = result.url;
const params = new URLSearchParams(url.split('#')[1] ?? url.split('?')[1] ?? '');
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
if (!accessToken || !refreshToken) {
// Session may already be set via onAuthStateChange — check
const { data: sessionData } = await supabase.auth.getSession();
if (sessionData.session) {
set({ session: sessionData.session, user: sessionData.session.user });
return {};
}
return { error: 'Session konnte nicht gelesen werden' };
}
const { data: sessionData, error: sessionError } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
});
if (sessionError) return { error: sessionError.message };
set({ session: sessionData.session, user: sessionData.session?.user ?? null });
return {};
},
resetPasswordForEmail: async (email) => {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'rebreak://auth/reset-password',
});
if (error) return { error: error.message };
return {};
},
verifyOtp: async (email, token) => {
const { data, error } = await supabase.auth.verifyOtp({
email,
token,
type: 'signup',
});
if (error) return { error: error.message };
if (!data.session) return { error: 'Bestätigung fehlgeschlagen bitte erneut versuchen.' };
set({ session: data.session, user: data.user ?? null });
return {};
},
resendConfirmation: async (email) => {
const { error } = await supabase.auth.resend({ type: 'signup', email });
if (error) return { error: error.message };
return {};
},
}));