Files
Sauci/docs/authentication.md
T
2025-12-29 23:10:12 +00:00

338 lines
11 KiB
Markdown

# Authentication System
This document explains how authentication works in Sauci, including supported methods, session management, and deep linking.
## Overview
Sauci uses Supabase Auth with three authentication methods:
1. **Magic Link** (passwordless email)
2. **Email/Password** with verification
3. **Apple Sign-In** (iOS native + OAuth fallback)
## Authentication Methods
### Magic Link
Passwordless authentication via email OTP.
```typescript
const { error } = await supabase.auth.signInWithOtp({
email: email.trim(),
options: {
emailRedirectTo: Linking.createURL("/(auth)/login"),
},
});
```
**Flow:**
1. User enters email
2. Supabase sends email with magic link
3. User taps link → Opens app via deep link
4. App extracts `token_hash` and verifies OTP
5. Session established
### Email/Password
Traditional email/password with email verification.
**Sign Up:**
```typescript
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password,
});
```
**Sign In:**
```typescript
const { error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
```
**Verification Flow:**
1. User signs up → Receives verification email
2. Shows "Verify your email" screen with:
- "I've verified my email" button (attempts sign-in)
- "Resend verification email" button (60s cooldown)
3. User clicks email link → Email verified
4. User taps "I've verified" → Sign-in succeeds
**Resend Logic:**
```typescript
const { error } = await supabase.auth.resend({
type: 'signup',
email: pendingVerification.email,
});
// 60 second cooldown between resends
```
### Apple Sign-In
Native Apple authentication on iOS with OAuth fallback.
**Native (iOS with expo-apple-authentication):**
```typescript
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthenticationScope.FULL_NAME,
AppleAuthenticationScope.EMAIL,
],
});
const { error } = await supabase.auth.signInWithIdToken({
provider: "apple",
token: credential.identityToken,
});
```
**OAuth Fallback (web, Expo Go):**
```typescript
const { error } = await supabase.auth.signInWithOAuth({
provider: "apple",
options: {
redirectTo: Linking.createURL("/(auth)/login"),
},
});
```
## Session Management
### Storage Adapter
Sessions are stored in AsyncStorage (not SecureStore due to JWT size limits):
```typescript
const ExpoStorageAdapter = {
getItem: async (key) => {
if (Platform.OS === "web") {
return window.localStorage.getItem(key);
}
return AsyncStorage.getItem(key);
},
setItem: async (key, value) => { ... },
removeItem: async (key) => { ... },
};
export const supabase = createClient(url, key, {
auth: {
storage: ExpoStorageAdapter,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: Platform.OS === "web",
},
});
```
### Auth State Listener
The root layout listens for auth changes:
```typescript
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
fetchUser();
setUserContext(session.user.id, session.user.email); // Sentry
} else if (event === "SIGNED_OUT") {
await clearPushToken(userId); // Remove push token
setUser(null);
clearUserContext();
queryClient.clear();
}
});
```
### Sign Out
Sign out clears all app state:
```typescript
signOut: async () => {
// Clear local state FIRST (ensures UI updates)
set({ user: null, couple: null, partner: null, ... });
// Clear other stores
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
// Sign out from Supabase (don't block on this)
await supabase.auth.signOut();
},
```
## Deep Linking
### URL Schemes
| Platform | Scheme | Example |
|----------|--------|---------|
| iOS/Android | `sauci://` | `sauci://auth/callback` |
| Expo Dev | `exp://` | `exp://localhost:8081` |
### Deep Link Handler
The root layout processes auth deep links:
```typescript
const handleDeepLink = async (url: string) => {
const parsedUrl = new URL(url);
const params = new URLSearchParams(parsedUrl.search);
const hashParams = new URLSearchParams(parsedUrl.hash.replace('#', ''));
const accessToken = params.get('access_token') || hashParams.get('access_token');
const refreshToken = params.get('refresh_token') || hashParams.get('refresh_token');
const tokenHash = params.get('token_hash') || hashParams.get('token_hash');
const type = params.get('type') || hashParams.get('type');
if (accessToken && refreshToken) {
// OAuth flow - set session directly
await supabase.auth.setSession({ access_token, refresh_token });
} else if (tokenHash && type) {
// Magic link / email verification
await supabase.auth.verifyOtp({ token_hash, type });
}
};
```
### Deep Link Sources
1. **Initial URL** - App opened via link (cold start)
2. **URL Event** - Link received while app running
```typescript
// Cold start
Linking.getInitialURL().then(handleDeepLink);
// App running
Linking.addEventListener("url", (event) => handleDeepLink(event.url));
```
## Profile Auto-Creation
Database trigger creates profile on signup:
```sql
CREATE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, name, avatar_url)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'name',
NEW.raw_user_meta_data->>'full_name'),
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
```
## Push Token Registration
After authentication, push token is registered:
```typescript
// In app layout after fetchUser
const token = await registerForPushNotificationsAsync();
if (token && user.id) {
await savePushToken(user.id, token);
}
```
Tokens are stored in `profiles.push_token` and cleared on sign out.
## Auth Flow Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ Login Screen │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Magic Link │ │ Password │ │ Apple │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ signInWithOtp │ │ signUp/signIn │ │ signInWithId │
│ │ │ WithPassword │ │ Token (native) │
└────────────────┘ └────────────────┘ └────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Email sent │ │ Verify email │ │ ID Token │
│ (magic link) │ │ (if signup) │ │ validated │
└────────────────┘ └────────────────┘ └────────────────┘
│ │ │
└──────────┬─────────┴────────────────────┘
▼
┌────────────────────┐
│ onAuthStateChange │
│ event: SIGNED_IN │
└──────────┬─────────┘
▼
┌────────────────────┐
│ fetchUser() │
│ Load profile from │
│ database │
└──────────┬─────────┘
▼
┌────────────────────┐
│ Register push │
│ token if granted │
└──────────┬─────────┘
▼
┌────────────────────┐
│ Navigate to app │
│ (or pairing if │
│ no partner) │
└────────────────────┘
```
## Supabase Auth Config
From `apps/supabase/config.toml`:
```toml
[auth]
enabled = true
site_url = "exp://localhost:8081"
additional_redirect_urls = ["sauci://auth/callback"]
jwt_expiry = 3600 # 1 hour
enable_refresh_token_rotation = true
refresh_token_reuse_interval = 10
enable_signup = true
[auth.external.apple]
enabled = true
client_id = "env(APPLE_CLIENT_ID)"
secret = "env(APPLE_CLIENT_SECRET)"
[auth.email]
enable_signup = true
double_confirm_changes = true
enable_confirmations = false # Email verification disabled by default
```
## Error Handling
| Error | Cause | Handling |
|-------|-------|----------|
| "Email not confirmed" | Sign-in before verification | Show verification pending screen |
| "Invalid login credentials" | Wrong password | Show error message |
| Empty identities array | Email already exists (signup) | Prompt to sign in instead |
| "ERR_REQUEST_CANCELED" | User canceled Apple sign-in | Silent (no error shown) |
## Security Considerations
- JWTs stored in AsyncStorage (encrypted on iOS, less secure on Android)
- Push tokens cleared on sign out
- Session auto-refresh enabled
- OAuth state verified via deep link parameters