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

11 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:

  1. Magic Link (passwordless email)
  2. Email/Password with verification
  3. Apple Sign-In (iOS native + OAuth fallback)

Authentication Methods

Passwordless authentication via email OTP.

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:

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:

  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:

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,
});

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

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 });
    }
};
  1. Initial URL - App opened via link (cold start)
  2. 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