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>
254 lines
9.0 KiB
TypeScript
254 lines
9.0 KiB
TypeScript
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 {};
|
||
},
|
||
}));
|