12 KiB
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:
- Magic Link (passwordless email)
- Email/Password with verification
- Apple Sign-In (iOS native + OAuth fallback)
Authentication Methods
Magic Link
Passwordless authentication via email OTP.
const { error } = await supabase.auth.signInWithOtp({
email: email.trim(),
options: {
emailRedirectTo: Linking.createURL("/(auth)/login"),
},
});
Flow:
- User enters email
- Supabase sends email with magic link
- User taps link → Opens app via deep link
- App extracts
token_hashand verifies OTP - Session established
Email/Password
Traditional email/password with email verification.
Sign Up:
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password,
});
Sign In:
const { error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
Verification Flow:
- User signs up → Receives verification email
- Shows "Verify your email" screen with:
- "I've verified my email" button (attempts sign-in)
- "Resend verification email" button (60s cooldown)
- User clicks email link → Email verified
- User taps "I've verified" → Sign-in succeeds
Resend Logic:
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):
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthenticationScope.FULL_NAME,
AppleAuthenticationScope.EMAIL,
],
});
const { error } = await supabase.auth.signInWithIdToken({
provider: "apple",
token: credential.identityToken,
});
Apple provides the selected full name only on the first native authorization. The mobile client formats it and reads the API-owned profile with the exact subject and bearer returned by that authorization. It writes the display name only when that matching profile name is blank, preserving existing nicknames. While this first-auth write is pending, the name is held only in subject-keyed mobile state so onboarding does not briefly require it again; failure clears that value and safely returns to the normal name step. Apple full/given/family names and email are not copied into Supabase user metadata.
Profile refreshes bind both /v1/me and couple-state reads to the session
subject and bearer captured at their start. A response is applied only while
that exact session remains current, and a returned profile must have the same
subject. This prevents an older request from replacing a newer account's local
profile, relationship state, or loading state. A subject mismatch clears the
in-memory user-scoped state rather than leaving a prior account visible.
OAuth Fallback (web, Expo Go):
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):
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:
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:
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:
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
- Initial URL - App opened via link (cold start)
- URL Event - Link received while app running
// Cold start
Linking.getInitialURL().then(handleDeepLink);
// App running
Linking.addEventListener("url", (event) => handleDeepLink(event.url));
Profile Auto-Creation
Database trigger creates profile on signup:
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:
// 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:
[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