Google Auth

This guide helps you add "Sign in with Google" to your Lovable app so it works on mobile devices through Despia. Your users will be able to tap a button and sign in with their Google account.

What You Need Before Starting

  • A Lovable project (your app)

  • A Despia account (to make it mobile)

  • Google OAuth already enabled in Lovable

If you don't have Google sign-in yet, first ask Lovable: "Add Google authentication to my app"

1

Get Your iOS Deep Link Scheme

Where to find it:

  1. Log into your Despia dashboard

  2. Select your app

  3. Go to Publish

  4. Copy the scheme shown (example: myapp://)

Important: Copy only the scheme name, not any :// or other characters.

Write it down - you'll need it in Step 3!

2

Get Your Android Information

You need two pieces of information from Despia:

Package Name:

  1. In Despia, find your Android bundle ID

  2. It looks like: com.yourcompany.appname

  3. Copy it exactly

SHA-256 Fingerprint:

  1. In Play Console Settings > Deep Links find "SHA-256 fingerprint" or "Signing certificate"

  2. It's a long string like: 1D:06:A8:20:42:1C:60:C3:C8:CE:6C:43:51:78:39:25...

  3. Copy the entire string

Write both of these down.

3

Copy and Paste Into Lovable

Open your Lovable project and paste this message into the chat.

Replace the UPPERCASE parts with your actual values from Steps 1 and 2.

COPY THIS MESSAGE:

My iOS deep link scheme is: [YOUR_IOS_SCHEME]
My Android package name is: [YOUR_ANDROID_PACKAGE]
My Android SHA-256 fingerprint is: [YOUR_SHA256_FINGERPRINT]
# Despia OAuth Integration - Production Guide

> **Note:** This guide is for current Despia versions that automatically handle OAuth provider opening. Just use standard `supabase.auth.signInWithOAuth()` - no manual window opening needed!

## šŸŽÆ Overview
Complete OAuth integration for Despia hybrid apps with Google authentication via Supabase.

**Platforms:** iOS (deep link), Android (standard web), Web (standard)

**āš ļø Critical Android Caveat:**

Android is simple - it uses standard web OAuth 1:1 with web. However, there's ONE critical rule:

**The OAuth callback route MUST NOT be in your `assetlinks.json` file.**

If you put `/auth/callback` in `assetlinks.json`, Android App Links will intercept the OAuth callback URL and strip the `?` query separator, turning:
```
https://app.com/auth/callback?access_token=xyz
                              ↓
https://app.com/auth/callbackaccess_token=xyz  āŒ BROKEN
```

**The Solution:** Use `/auth/web-callback` (which Android doesn't know about) so the URL loads normally in the WebView with all query parameters intact.

**Why This Happens:** Android App Links work at the domain level and strip query separators during deep link processing. Unlike iOS Universal Links (which support path-level exclusions), the only way to prevent interception is to not mention the path in `assetlinks.json` at all.

**Note on OAuth Providers:** 
- **iOS**: Use `skipBrowserRedirect: true` and manually `window.open(data.url, '_blank')` to prevent Supabase auto-redirect and let Despia control in-app browser opening
- **Android**: Use `window.location.href` and Despia automatically opens in external browser (Chrome/etc)

This gives you full control over OAuth flow on both platforms.

---

## 🚨 CRITICAL RULES

### RULE #1: iOS Deep Link Triggering

**On iOS, custom URL schemes MUST use `window.open()`, NOT `window.location.href`**

āŒ **WRONG:**
```typescript
window.location.href = 'myapp://?link=...'; // FAILS
```

āœ… **CORRECT:**
```typescript
window.open('myapp://?link=...', '_self'); // āœ… Works
```

### RULE #2: Android App Links Configuration

**🚨 CRITICAL: OAuth callback routes MUST NOT be in assetlinks.json**

**Why:** Android App Links strip the `?` query separator when intercepting URLs, turning:
- `https://app.com/auth/callback?access_token=xyz` 
- Into: `https://app.com/auth/callbackaccess_token=xyz` āŒ

**Solution:** Use separate callback routes:
- `/auth/mobile-callback` - iOS only (can be in App Links)
- `/auth/web-callback` - Android & Web (must NOT be in App Links)

**The Complete Explanation:**

**āŒ What happens with `/auth/callback` (BROKEN):**
1. Google OAuth redirects to: `https://app.com/auth/callback?access_token=...`
2. Android App Links sees the domain matches `assetlinks.json`
3. Android intercepts and strips the `?` during deep link handling
4. URL becomes: `/auth/callbackaccess_token=...` (no `?`)
5. WebCallback can't parse parameters → Authentication fails

**āœ… What happens with `/auth/web-callback` (WORKS):**
1. Google OAuth redirects to: `https://app.com/auth/web-callback?access_token=...`
2. Android App Links checks `assetlinks.json`
3. Path not configured → Android lets WebView handle it normally
4. URL loads with intact parameters: `?access_token=...&refresh_token=...`
5. WebCallback successfully parses tokens → Authentication succeeds! šŸŽ‰

**Key Insight:** Android App Links work at the **domain level**, not path level. Unlike iOS Universal Links (which support path exclusions), Android intercepts ALL routes for a configured domain. The ONLY way to prevent interception is to not configure that specific path anywhere in your App Links setup.

---

## šŸ“‹ File Structure

```
src/
ā”œā”€ā”€ lib/detectEnvironment.ts
ā”œā”€ā”€ utils/authRetry.ts (iOS only)
ā”œā”€ā”€ pages/auth/
│   ā”œā”€ā”€ Auth.tsx
│   ā”œā”€ā”€ MobileCallback.tsx (iOS only)
│   ā”œā”€ā”€ AuthFinish.tsx (iOS only)
│   └── WebCallback.tsx (Android & Web)
└── integrations/supabase/client.ts

public/.well-known/
└── assetlinks.json (Android - NO callback routes!)
```

---

## šŸ”§ Environment Variables

```env
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-anon-key
VITE_APP_URL=https://your-app.lovable.app
VITE_DEEP_LINK_SCHEME=myapp  # iOS only
```

---

## šŸ› ļø Implementation

### 1. Platform Detection (`src/lib/detectEnvironment.ts`)

```typescript
const MOBILE_UA_TOKEN = 'despia';

export type Platform = 'ios' | 'android' | 'web';

export function detectPlatform(): Platform {
  const ua = navigator.userAgent.toLowerCase();
  if (!ua.includes(MOBILE_UA_TOKEN)) return 'web';
  if (ua.includes('iphone') || ua.includes('ipad')) return 'ios';
  if (ua.includes('android')) return 'android';
  return 'web';
}

export function getAppUrl(): string {
  return import.meta.env.VITE_APP_URL || window.location.origin;
}

export function getDeepLinkScheme(): string {
  return import.meta.env.VITE_DEEP_LINK_SCHEME || 'myapp';
}
```

---

### 2. Session Retry Utility (`src/utils/authRetry.ts`) - iOS ONLY

```typescript
import { Session } from '@supabase/supabase-js';
import { supabase } from '@/integrations/supabase/client';

export interface RetryResult {
  session: Session | null;
  attempts: number;
}

export async function retryGetSession(
  maxAttempts = 3,
  timeoutMs = 10000
): Promise<RetryResult> {
  const delays = [0, 200, 500];
  let attempts = 0;
  const startTime = Date.now();

  for (let i = 0; i < maxAttempts; i++) {
    if (Date.now() - startTime > timeoutMs) break;

    attempts++;
    if (delays[i] > 0) {
      await new Promise(resolve => setTimeout(resolve, delays[i]));
    }

    const { data: { session } } = await supabase.auth.getSession();
    if (session?.access_token && session?.refresh_token) {
      return { session, attempts };
    }
  }

  return { session: null, attempts };
}

export function buildDeepLink(
  session: Session,
  baseUrl: string,
  scheme: string
): string {
  const tokenData = btoa(JSON.stringify({ 
    a: session.access_token, 
    r: session.refresh_token 
  }));
  const finishUrl = `${baseUrl}/auth/finish?t=${encodeURIComponent(tokenData)}`;
  return `${scheme}://?link=${finishUrl}`;
}
```

---

### 3. Auth Page (`src/pages/Auth.tsx`)

```typescript
import { detectPlatform, getAppUrl } from '@/lib/detectEnvironment';
import { supabase } from '@/integrations/supabase/client';

const handleGoogleAuth = async () => {
  try {
    const platform = detectPlatform();
    const baseUrl = getAppUrl();
    const ua = navigator.userAgent.toLowerCase();
    
    // CRITICAL: Use separate callback routes
    // iOS: /auth/mobile-callback - needs custom deep link handling
    // Android/Web: /auth/web-callback - must NOT be in assetlinks.json to prevent query param stripping
    const redirectTo = platform === 'ios'
      ? `${baseUrl}/auth/mobile-callback?returnTo=/&platform=ios`
      : `${baseUrl}/auth/web-callback?returnTo=/`;

    console.log('[Auth] Platform:', platform, 'RedirectTo:', redirectTo);

    const { data, error } = await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo,
        queryParams: {
          access_type: 'offline',
          prompt: 'consent',
        },
      },
    });

    if (error) throw error;

    if (data?.url) {
      // iOS: _blank opens in-app browser
      if (ua.includes('despia') && (ua.includes('ipad') || ua.includes('iphone'))) {
        window.open(data.url, '_blank');
      } 
      // Android/Web: Standard redirect (Despia opens accounts.google.com in external browser automatically)
      else {
        window.location.href = data.url;
      }
    }
  } catch (error) {
    console.error('[Auth] Error:', error);
  }
};
```

**🚨 Critical:** 
- iOS → `/auth/mobile-callback` (can be in App Links)
- Android → `/auth/web-callback` (must NOT be in App Links)

---

### 4. Web Callback (`src/pages/auth/WebCallback.tsx`) - ANDROID & WEB

```typescript
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';

export default function WebCallback() {
  const navigate = useNavigate();
  const [error, setError] = useState('');

  useEffect(() => {
    const handleCallback = async () => {
      try {
        let currentUrl = window.location.href;
        console.log('[WebCallback] Original URL:', currentUrl);

        // CRITICAL FIX: Normalize URLs in case App Links stripped the '?'
        // Handles: /auth/callbackaccess_token= → /auth/callback?access_token=
        currentUrl = currentUrl.replace(
          /(\/auth\/(?:web-)?callback|\/callback)(access_token|code|refresh_token)=/g,
          '$1?$2='
        );
        
        // Ensure '&' separators between parameters
        currentUrl = currentUrl.replace(/([^?&])(?=(code|access_token|refresh_token)=)/g, '$1&');
        
        console.log('[WebCallback] Normalized URL:', currentUrl);

        const url = new URL(currentUrl);
        const params = new URLSearchParams(url.search);
        
        // Check hash for tokens (fallback)
        if (url.hash) {
          const hashParams = new URLSearchParams(url.hash.substring(1));
          hashParams.forEach((value, key) => {
            if (!params.has(key)) params.set(key, value);
          });
        }

        const code = params.get('code');
        const access_token = params.get('access_token');
        const refresh_token = params.get('refresh_token');

        // Check existing session
        const { data: { session } } = await supabase.auth.getSession();
        if (session) {
          console.log('[WebCallback] Session exists');
          navigate(params.get('returnTo') || '/');
          return;
        }

        // Handle direct tokens
        if (access_token && refresh_token) {
          console.log('[WebCallback] Setting session from tokens');
          const { error } = await supabase.auth.setSession({
            access_token,
            refresh_token,
          });
          if (error) throw error;
          navigate(params.get('returnTo') || '/');
          return;
        }

        // Handle authorization code
        if (code) {
          console.log('[WebCallback] Exchanging code for session');
          const { error } = await supabase.auth.exchangeCodeForSession(code);
          if (error) throw error;
          navigate(params.get('returnTo') || '/');
          return;
        }

        throw new Error('No authentication data found');

      } catch (err) {
        console.error('[WebCallback] Error:', err);
        setError(err instanceof Error ? err.message : 'Unknown error');
      }
    };

    handleCallback();
  }, [navigate]);

  if (error) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="text-red-600">Error: {error}</div>
      </div>
    );
  }

  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
    </div>
  );
}
```

**Android Flow:**
1. User approves OAuth
2. Google redirects to `https://app.com/auth/web-callback?code=...`
3. URL stays in WebView (not in assetlinks.json)
4. Query parameters intact
5. Session established āœ“

---

### 5. iOS Mobile Callback (`src/pages/auth/MobileCallback.tsx`) - iOS ONLY

```typescript
import { useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { getAppUrl, getDeepLinkScheme } from '@/lib/detectEnvironment';
import { retryGetSession, buildDeepLink } from '@/utils/authRetry';

export default function MobileCallback() {
  const [searchParams] = useSearchParams();
  const [viewState, setViewState] = useState<'loading' | 'success' | 'error'>('loading');
  const [deepLinkRaw, setDeepLinkRaw] = useState('');
  const [errorMessage, setErrorMessage] = useState('');
  const debug = searchParams.get('debug') === '1';
  const autoRedirectTimer = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    const initAuth = async () => {
      try {
        const baseUrl = getAppUrl();
        const scheme = getDeepLinkScheme();

        const { session } = await retryGetSession(3, 10000);

        if (!session) {
          setErrorMessage('No session found. Please try logging in again.');
          setViewState('error');
          return;
        }

        const deepLink = buildDeepLink(session, baseUrl, scheme);
        setDeepLinkRaw(deepLink);
        setViewState('success');

      } catch (error) {
        setErrorMessage(error instanceof Error ? error.message : 'Unknown error');
        setViewState('error');
      }
    };

    initAuth();
  }, [searchParams]);

  useEffect(() => {
    if (deepLinkRaw && viewState === 'success' && !debug) {
      autoRedirectTimer.current = setTimeout(() => {
        // 🚨 CRITICAL: iOS requires window.open()
        window.open(deepLinkRaw, '_self');
      }, 150);

      return () => {
        if (autoRedirectTimer.current) clearTimeout(autoRedirectTimer.current);
      };
    }
  }, [deepLinkRaw, viewState, debug]);

  const handleOpenDeepLink = (e: React.MouseEvent) => {
    e.preventDefault();
    if (autoRedirectTimer.current) {
      clearTimeout(autoRedirectTimer.current);
      autoRedirectTimer.current = null;
    }
    window.open(deepLinkRaw, '_self');
  };

  if (viewState === 'loading') {
    return (
      <div className="flex flex-col items-center justify-center min-h-screen p-4">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
        <p className="mt-4 text-gray-600">Authenticating...</p>
      </div>
    );
  }

  if (viewState === 'error') {
    return (
      <div className="flex flex-col items-center justify-center min-h-screen p-4">
        <div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
          <h2 className="text-xl font-semibold text-red-800 mb-2">Error</h2>
          <p className="text-red-600 mb-4">{errorMessage}</p>
          <button
            onClick={() => window.location.reload()}
            className="w-full bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
          >
            Try Again
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="flex flex-col items-center justify-center min-h-screen p-4">
      <div className="bg-green-50 border border-green-200 rounded-lg p-6 max-w-md">
        <h2 className="text-xl font-semibold text-green-800 mb-2">Success!</h2>
        <p className="text-green-600 mb-4">Opening app...</p>
        <button
          onClick={handleOpenDeepLink}
          className="w-full bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
        >
          Open in App
        </button>
      </div>
    </div>
  );
}
```

---

### 6. Auth Finish (`src/pages/auth/AuthFinish.tsx`) - iOS ONLY

```typescript
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';

export default function AuthFinish() {
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const [status, setStatus] = useState<'working' | 'success' | 'error'>('working');
  const [countdown, setCountdown] = useState(3);

  useEffect(() => {
    const establishSession = async () => {
      try {
        const compactToken = searchParams.get('t');
        
        if (compactToken) {
          const decoded = JSON.parse(atob(compactToken));
          const { a: access_token, r: refresh_token } = decoded;

          if (!access_token || !refresh_token) {
            throw new Error('Invalid token data');
          }

          const { error } = await supabase.auth.setSession({
            access_token,
            refresh_token,
          });

          if (error) throw error;
          setStatus('success');
          return;
        }

        const { data: { session } } = await supabase.auth.getSession();
        if (session) {
          setStatus('success');
          return;
        }

        throw new Error('No authentication data found');

      } catch (error) {
        console.error('[AuthFinish] Error:', error);
        setStatus('error');
      }
    };

    establishSession();
  }, [searchParams]);

  useEffect(() => {
    if (status === 'success') {
      const timer = setInterval(() => {
        setCountdown((prev) => {
          if (prev <= 1) {
            clearInterval(timer);
            navigate('/');
            return 0;
          }
          return prev - 1;
        });
      }, 1000);

      return () => clearInterval(timer);
    }
  }, [status, navigate]);

  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="text-center">
        {status === 'working' && (
          <>
            <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
            <p className="mt-4">Establishing session...</p>
          </>
        )}
        {status === 'success' && (
          <>
            <div className="text-green-600 text-xl mb-4">āœ“ Success!</div>
            <p>Redirecting in {countdown}...</p>
          </>
        )}
        {status === 'error' && (
          <div className="text-red-600">Authentication failed</div>
        )}
      </div>
    </div>
  );
}
```

---

## šŸ“± Android App Links Setup - CRITICAL CONFIGURATION

### `public/.well-known/assetlinks.json`

```json
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.your.package",
    "sha256_cert_fingerprints": ["YOUR_SHA256_FINGERPRINT"]
  }
}]
```

### 🚨 CRITICAL ANDROID RULES

**āŒ DO NOT include OAuth callback routes in assetlinks.json:**
- `/auth/callback` āŒ
- `/auth/web-callback` āŒ
- `/callback` āŒ

**Why:** Android App Links strip the `?` query separator when intercepting URLs:
- Input: `https://app.com/auth/callback?code=xyz`
- Output: `https://app.com/auth/callbackcode=xyz` (broken!)

**āœ… DO include app-specific routes:**
- `/auth/mobile-callback` āœ“ (iOS only, safe)
- `/dashboard` āœ“
- `/profile` āœ“
- Any internal app routes āœ“

**How Android OAuth Opens External Browser:**
- Despia automatically handles OAuth provider domains (`accounts.google.com`)
- When OAuth URL is triggered, Despia opens it in the device's external browser
- No "Always Open in Browser" configuration needed in Despia settings
- After OAuth completes, callback URL loads in your app's WebView

### Get SHA-256 Fingerprint

```bash
# Production APK
keytool -list -v -keystore /path/to/keystore.jks

# Debug build
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android

# From Google Play Console
# Release > Setup > App integrity > App signing tab
```

### Verification

- [ ] File at `https://yourdomain.com/.well-known/assetlinks.json`
- [ ] Returns `Content-Type: application/json`
- [ ] No redirects (301/302)
- [ ] Package name matches exactly
- [ ] SHA-256 fingerprint correct
- [ ] **Does NOT include `/auth/web-callback` or any callback routes**
- [ ] Test: `curl -I https://yourdomain.com/.well-known/assetlinks.json`

### Despia Configuration Notes

**Both iOS and Android:**
- OAuth provider domains (`accounts.google.com`, `appleid.apple.com`, etc.) are handled automatically by Despia
- No "Always Open in Browser" configuration needed
- Just call `supabase.auth.signInWithOAuth()` and Despia does the rest
- **iOS**: Opens OAuth in in-app browser automatically
- **Android**: Opens OAuth in external browser automatically

---

## šŸŽÆ Flow Comparison

### iOS Flow (Custom)
```
Auth.tsx → supabase.auth.signInWithOAuth()
  ↓
Despia detects accounts.google.com → Opens in in-app browser automatically
  ↓
User approves OAuth in in-app browser
  ↓
Google OAuth completes
  ↓
/auth/mobile-callback (waits for session with retry)
  ↓
Generates: myapp://?link=https://app.com/auth/finish?t=TOKENS
  ↓
window.open(deepLink, '_self') ← 🚨 CRITICAL
  ↓
Despia intercepts → closes in-app browser
  ↓
/auth/finish decodes tokens → setSession() āœ“
```

### Android Flow - BROKEN āŒ (Using `/auth/callback`)
```
Auth.tsx → supabase.auth.signInWithOAuth()
  ↓
Despia detects accounts.google.com → Opens in external browser automatically
  ↓
User approves OAuth in external browser (Chrome/etc)
  ↓
Google OAuth completes
  ↓
Redirects to: https://app.com/auth/callback?access_token=xyz&refresh_token=abc
  ↓
Android App Links: "This domain is mine, intercepting!"
  ↓
During interception: Query separator stripped
  ↓
Becomes: https://app.com/auth/callbackaccess_token=xyz&refresh_token=abc
  ↓
WebCallback tries to parse → No '?' found → No params → FAIL āŒ
```

### Android Flow - WORKING āœ… (Using `/auth/web-callback`)
```
Auth.tsx → supabase.auth.signInWithOAuth()
  ↓
Despia detects accounts.google.com → Opens in external browser automatically
  ↓
User approves OAuth in external browser (Chrome/etc)
  ↓
Google OAuth completes
  ↓
Redirects to: https://app.com/auth/web-callback?access_token=xyz&refresh_token=abc
  ↓
Android App Links: "Not configured for this path, skip interception"
  ↓
Loads in Despia app WebView normally
  ↓
Query parameters stay intact: ?access_token=xyz&refresh_token=abc
  ↓
WebCallback parses successfully → exchangeCodeForSession() → SUCCESS āœ“
```

**The Critical Difference:** One character change in the route name prevents Android from intercepting and destroying the URL.

---

## šŸ“Œ Quick Reference

### Android OAuth - The One Critical Rule

```typescript
// āŒ BROKEN - Android intercepts and strips '?'
redirectTo: `${baseUrl}/auth/callback`

// āœ… WORKS - Android doesn't intercept, params stay intact  
redirectTo: `${baseUrl}/auth/web-callback`
```

**What happens:**
1. `/auth/callback` in assetlinks.json → Android intercepts → strips `?` → breaks auth
2. `/auth/web-callback` NOT in assetlinks.json → loads in WebView → params intact → auth succeeds

**Auth flow simplified (new Despia):**
```typescript
// Just use standard Supabase OAuth - Despia handles the rest!
await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: { redirectTo: platform === 'ios' ? '/auth/mobile-callback' : '/auth/web-callback' }
});
```

**Remember:** 
- Android App Links = domain-level (can't exclude paths like iOS)
- Solution = use route Android doesn't know about
- `/auth/web-callback` is that route
- Despia automatically opens OAuth providers correctly on both platforms

---

## āœ… Testing Checklist

### iOS
- [ ] Deep links use `window.open(deepLink, '_self')` (in callback pages)
- [ ] OAuth opens automatically in in-app browser
- [ ] Routes to `/auth/mobile-callback`
- [ ] Auto-redirect works
- [ ] Session persists after restart

### Android
- [ ] Routes to `/auth/web-callback`
- [ ] OAuth opens automatically in external browser
- [ ] Callback returns to app via App Links
- [ ] URL contains `?code=...` or `?access_token=...`
- [ ] Parameters NOT stripped
- [ ] Session establishes
- [ ] Session persists after restart

### Supabase
- [ ] Redirect URLs allowlisted:
  - `/auth/mobile-callback` (iOS)
  - `/auth/web-callback` (Android & Web)
  - `/auth/finish` (iOS)

---

## šŸ› Troubleshooting

### Android: "No authentication data found"

**Symptom**: WebCallback can't find `code` or tokens in URL

**Cause #1**: `/auth/web-callback` is in assetlinks.json
- **Fix**: Remove it from assetlinks.json
- App Links are stripping the `?` separator

**Cause #2**: Wrong callback route in Auth.tsx
- **Fix**: Ensure Android uses `/auth/web-callback`, not `/auth/callback`

**Cause #3**: Malformed URL
- **Fix**: The URL normalization in WebCallback.tsx should handle this
- Check console logs for original vs normalized URL

### Android: Parameters Glued Together

**Symptom**: URL shows `/auth/callbackcode=xyz` instead of `/auth/callback?code=xyz`

**Cause**: App Links intercepted the URL and stripped `?`

**Fix**: The URL normalization in WebCallback.tsx handles this:
```typescript
currentUrl = currentUrl.replace(
  /(\/auth\/(?:web-)?callback|\/callback)(access_token|code|refresh_token)=/g,
  '$1?$2='
);
```

**Prevention**: Don't include callback routes in assetlinks.json

### iOS: Deep Link Not Triggering

**Symptom**: In-app browser stays open

**Fix**: Use `window.open(deepLink, '_self')`, NOT `window.location.href`

### iOS: Timer Race Condition Crash

**Fix**: Clear timer before manual redirect
```typescript
if (autoRedirectTimer.current) {
  clearTimeout(autoRedirectTimer.current);
  autoRedirectTimer.current = null;
}
window.open(deepLinkRaw, '_self');
```

---

## šŸš€ Implementation Checklist

### 0. Before Starting
- [ ] Get iOS deep link scheme from Despia
- [ ] Understand: Android callback must NOT be in assetlinks.json

### 1. Auth Logic
- [ ] Use standard `supabase.auth.signInWithOAuth()` call
- [ ] iOS → `/auth/mobile-callback`
- [ ] Android → `/auth/web-callback`
- [ ] No manual window opening needed - Despia handles OAuth providers automatically

### 2. Callback Pages
- [ ] iOS: `MobileCallback.tsx` + `AuthFinish.tsx`
- [ ] Android/Web: `WebCallback.tsx` (shared)
- [ ] Add URL normalization to WebCallback.tsx

### 3. Utilities
- [ ] `detectEnvironment.ts`
- [ ] `authRetry.ts` (iOS only)

### 4. Android Configuration
- [ ] Create `assetlinks.json`
- [ ] Add package name
- [ ] Add SHA-256 fingerprint
- [ ] **Verify callback routes NOT included**
- [ ] Deploy and test accessibility

### 5. Supabase
- [ ] Allowlist redirect URLs
- [ ] Test OAuth flow on all platforms

### 6. Despia Settings
- [ ] iOS: Set deep link scheme
- [ ] Android: Set package name
- [ ] **No "Always Open in Browser" configuration needed** - Despia handles OAuth providers automatically

---

## šŸ“‹ Key Takeaways

### iOS (Complex)
- Custom deep link flow required for callback handling
- Must use `window.open()` for deep links (in callback pages only)
- OAuth opens automatically in in-app browser (Despia handles this)
- Needs session retry logic
- Routes: `/auth/mobile-callback` → `/auth/finish`

### Android (Simple BUT...)
- Uses standard web OAuth 1:1
- **CRITICAL**: Callback route must NOT be in assetlinks.json
- If in assetlinks.json, App Links strip `?` separator
- OAuth opens automatically in external browser (Despia handles this)
- Callback returns via App Links to WebView
- Routes: `/auth/web-callback` (same as web)
- URL normalization handles edge cases

### The Android App Links Mechanism

**Why the specific route name matters:**

The change from `/auth/callback` to `/auth/web-callback` is not arbitrary - it's the ENTIRE solution:

1. **Domain-Level Control**: Android App Links work at the domain level (unlike iOS which supports path-level control)
2. **Interception Behavior**: Once `assetlinks.json` configures a domain, Android wants to intercept ALL URLs from that domain
3. **Query Parameter Stripping**: When Android intercepts a URL, it strips the `?` during deep link processing
4. **The Fix**: By using a route Android doesn't know about (`/auth/web-callback`), the URL loads normally in the WebView where query parameters stay intact

**This is why:**
- `/auth/callback` fails (Android knows about it → intercepts → strips `?`)
- `/auth/web-callback` works (Android doesn't know → loads in WebView → params intact)

iOS doesn't have this problem because it uses custom deep links (`myapp://`) with tokens embedded in the URL scheme itself, not as query parameters.

### How OAuth Providers Open (Automatic in New Despia)

**Both iOS and Android:**
- Just call `supabase.auth.signInWithOAuth()` normally
- Despia automatically detects OAuth provider domains (`accounts.google.com`, `appleid.apple.com`, etc.)
- **iOS**: Automatically opens in Despia's in-app browser
- **Android**: Automatically opens in device's external browser (Chrome/etc)
- No manual `window.open()` or `window.location.href` needed
- No `skipBrowserRedirect: true` needed
- No "Always Open in Browser" configuration needed

**The magic is in the Despia runtime** - it intercepts OAuth provider URLs and handles them appropriately for each platform.
4

Configure Supabase Redirect URLs

What this does: Tells Google where to send users after they sign in.

Steps:

  1. Open your Supabase project dashboard

    • In Lovable: Settings > Integrations > Supabase > Click the link
  2. In Supabase dashboard:

    • Click "Authentication" in the left sidebar

    • Click "URL Configuration"

    • Scroll to "Redirect URLs"

  3. Add these three URLs (replace YOUR-APP-NAME with your actual Lovable app URL):

https://YOUR-APP-NAME.lovable.app/auth/mobile-callback
https://YOUR-APP-NAME.lovable.app/auth/web-callback
https://YOUR-APP-NAME.lovable.app/auth/finish

Finding your app URL: Look in your Lovable project settings under "Deployment" or "Domain".

  1. Click "Save"
5

Deploy

  1. In Lovable, click "Update" (top right)

  2. Wait for deployment to complete (1-2 minutes)

  3. Your app is now live with Google sign-in

6

Test Your App

Test on iPhone:

  1. Open your app on an iPhone

  2. Tap "Sign in with Google"

  3. Google sign-in page appears

  4. Enter your Google credentials

  5. App opens automatically, you're logged in

Test on Android:

  1. Open your app on an Android phone

  2. Tap "Sign in with Google"

  3. Chrome opens with Google sign-in

  4. Enter your Google credentials

  5. App opens automatically, you're logged in

Important: Test on actual phones, not simulators.

About the implementation: This setup uses a battle-tested OAuth flow that handles platform differences automatically. iOS uses deep links because of how its in-app browser works. Android uses App Links for seamless return to the app. Both provide the same smooth user experience.

Security: All authentication goes through Google's secure OAuth system and Supabase's authentication service. Tokens are handled securely and sessions persist correctly on both platforms.

Maintenance: Once set up, this requires no ongoing maintenance. Users can sign in reliably on all platforms.

Updated on