Files
Sauci/docs/authentication.md

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:

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

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

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