Apple OAuth

Access native Apple Sign In using ASWebAuthenticationSession and Chrome Custom Tabs via a simple to use SDK

Lovable Prompt
Lovable Prompt

This setup is more technical and you'll need to paste the following prompt, which includes full scaffolding, into Lovable to get good results:

# Despia Native SDK - Apple Sign In Integration Guide

Production ready implementation for Lovable + Supabase (Custom Provider) using redirect flow

---

## What This Does

This implements Apple Sign In that works in:
1. Web browsers - Using redirect flow with form_post
2. Native mobile apps - When your Lovable web app is wrapped with Despia SDK

### Why Custom Implementation?

While Supabase has Apple OAuth support, this custom implementation gives you:
1. Full control over the authentication flow
2. Custom user metadata handling
3. Consistent experience across web and native
4. Private email relay handling for users who hide their email

### Apple Sign In Flow Overview

```
User clicks "Sign in with Apple"
    -> Browser redirects to Apple authorization page
    -> User authorizes with Face ID/Touch ID/password
    -> Apple POSTs to edge function (form_post required for name/email scopes)
    -> Edge function verifies identity token with Apple's public keys
    -> Edge function creates/authenticates Supabase user
    -> Edge function redirects to frontend with tokens in URL
    -> Frontend calls setSession() with tokens
    -> User logged in
```

---

## Architecture

### Web Flow

```
LoginButton.tsx
    -> window.location.href = Apple OAuth URL
    -> User authenticates with Apple
    -> Apple POSTs { id_token, code, user } to edge function
    -> Edge function does EVERYTHING:
       1. Parse form POST data
       2. Verify Apple identity token (JWT)
       3. Extract user info from token
       4. Create/find Supabase user
       5. Generate magic link + verify OTP = real session
       6. Redirect to /auth?access_token=xxx&refresh_token=yyy
    -> Auth.tsx extracts tokens from URL
    -> Auth.tsx calls setSession() with tokens
    -> User logged in
```

### Native Flow (Despia WebView)

```
LoginButton.tsx
    -> Detect native environment
    -> despia('oauth://?url=...') opens ASWebAuthenticationSession
    -> User authenticates with Apple (native UI)
    -> Apple POSTs to edge function
    -> Edge function processes and redirects to /auth with tokens + state
    -> Edge function detects native state, redirects to deeplink
    -> myapp://oauth/auth?access_token=xxx (closes browser)
    -> WebView navigates to /auth?access_token=xxx
    -> Auth.tsx calls setSession() with tokens
    -> User logged in
```

---

## CRITICAL: Why This Works (Lessons Learned)

### WRONG: Manual JWT Creation

```typescript
// This WILL NOT WORK - creates "bad_jwt" errors
import * as jose from "jose";
const jwt = await new jose.SignJWT({ sub: userId, ... }).sign(secret);
```

Supabase tokens have specific claims, signatures, and internal state that manual JWTs don't have.

### CORRECT: Magic Link + OTP Verification

```typescript
// This creates REAL Supabase tokens that work everywhere
const { data: linkData } = await supabaseAdmin.auth.admin.generateLink({
  type: 'magiclink',
  email: userEmail,
});

const { data: sessionData } = await supabasePublic.auth.verifyOtp({
  token_hash: linkData.properties.hashed_token,
  type: 'email',
});
// sessionData.session.access_token = real, working token!
```

### Key Requirements

1. **Two Supabase clients** in the edge function:
   - `supabaseAdmin` (service role key) - for creating users, generating links
   - `supabasePublic` (anon key) - for verifying OTP to create session

2. **Always set `email_confirm: true`** when creating OAuth users

3. **Use `setSession()`** on the frontend, not just storing tokens

4. **`response_mode: 'form_post'`** - Required by Apple when requesting name/email scopes

5. **Edge function handles POST** - Apple POSTs form data, edge function redirects to frontend

---

## CRITICAL: Required Components

| Component | What It Does | Without It |
|-----------|--------------|------------|
| **VITE_APPLE_CLIENT_ID** (.env) | Your Apple Service ID (frontend) | ⚠️ Console warning, auth fails |
| **VITE_SUPABASE_URL** (.env) | Your Supabase project URL (frontend) | ⚠️ Console warning, wrong redirects |
| **APPLE_CLIENT_ID** (Supabase) | Your Apple Service ID (backend) | Token verification fails |
| **APPLE_TEAM_ID** (Supabase) | Your Apple Developer Team ID | Token verification fails |
| **APPLE_KEY_ID** (Supabase) | Your Apple Sign In Key ID | Client secret generation fails |
| **APPLE_PRIVATE_KEY** (Supabase) | Your Apple .p8 key contents | Can't generate client secret |
| **APP_URL** (Supabase) | Your frontend URL for redirects | Wrong redirect after auth |
| auth-apple-callback | Edge function - receives POST, redirects | Can't complete login |
| LoginButton.tsx | Starts Apple Sign In flow | Nothing happens on click |
| Auth.tsx | Receives tokens, sets session | Login doesn't complete |

---

## Complete File List

| # | File Path | Purpose |
|---|-----------|---------|
| 1 | .env | Frontend environment variables |
| 2 | src/vite-env.d.ts | TypeScript types for env variables |
| 3 | supabase/functions/auth-apple-callback/index.ts | Edge function - receives Apple POST, redirects to frontend |
| 4 | src/lib/apple-auth.ts | Apple auth helper functions |
| 5 | src/components/LoginButton.tsx | Login button - redirects to Apple |
| 6 | src/pages/Auth.tsx | Receives tokens from URL, sets session |
| 7 | src/App.tsx | Route configuration |
| 8 | supabase/config.toml | Edge function config |
| 9 | public/404.html | SPA fallback |
| 10 | src/components/SpaRedirector.tsx | Handle 404 redirect |
| 11 | public/_redirects | Netlify/Lovable routing |

---

## Step 1: Set Up Apple Developer Account

### 1.1 Create an App ID

1. Go to https://developer.apple.com/account/resources/identifiers/list
2. Click the **+** button to create a new identifier
3. Select **App IDs** and click **Continue**
4. Select **App** type and click **Continue**
5. Fill in:
   - **Description**: Your app name (e.g., "My Awesome App")
   - **Bundle ID**: Explicit, e.g., `com.yourcompany.yourapp`
6. Scroll down to **Capabilities** and check **Sign In with Apple**
7. Click **Continue** then **Register**

### 1.2 Create a Service ID (for web)

1. Go to https://developer.apple.com/account/resources/identifiers/list
2. Click the **+** button
3. Select **Services IDs** and click **Continue**
4. Fill in:
   - **Description**: "My App Web Auth"
   - **Identifier**: `com.yourcompany.yourapp.web` (must be different from App ID)
5. Click **Continue** then **Register**
6. Click on the newly created Service ID
7. Check **Sign In with Apple** and click **Configure**
8. Configure:
   - **Primary App ID**: Select the App ID you created
   - **Domains and Subdomains**: 
     - `your-app.lovable.app`
     - `your-project-ref.supabase.co` (for edge function)
   - **Return URLs**: 
     - `https://your-project-ref.supabase.co/functions/v1/auth-apple-callback`
9. Click **Save** then **Continue** then **Save**

> **Important**: The Return URL must be your Supabase edge function URL because Apple uses `response_mode: form_post` which POSTs directly to your server. The edge function then redirects to your frontend.

### 1.3 Create a Sign In with Apple Key

1. Go to https://developer.apple.com/account/resources/authkeys/list
2. Click the **+** button
3. Fill in:
   - **Key Name**: "Sign In with Apple Key"
4. Check **Sign In with Apple** and click **Configure**
5. Select your **Primary App ID** and click **Save**
6. Click **Continue** then **Register**
7. **IMPORTANT**: Download the `.p8` key file (you can only download it once!)
8. Note the **Key ID** displayed

### 1.4 Note Your Credentials

You'll need these values:
- **Team ID**: Found in top right of Apple Developer portal (e.g., `ABCD1234EF`)
- **Service ID**: The identifier you created (e.g., `com.yourcompany.yourapp.web`)
- **Key ID**: From the key you created (e.g., `ABC123DEFG`)
- **Private Key**: Contents of the `.p8` file

---

## Step 2: Add Environment Variables

### Frontend Configuration (.env file)

Create a `.env` file in your project root:

```env
VITE_APPLE_CLIENT_ID=com.yourcompany.yourapp.web
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
```

**This is safe** because the client_id is public (it's visible in the OAuth flow anyway).

> **Note**: In Lovable, you can set environment variables in Project Settings → Environment Variables.

### In Supabase Dashboard (Server-side Secrets)

Go to Project Settings → Edge Functions → Add secrets:

```
APPLE_CLIENT_ID=com.yourcompany.yourapp.web
APPLE_TEAM_ID=ABCD1234EF
APPLE_KEY_ID=ABC123DEFG
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIGT...your key content...AB12\n-----END PRIVATE KEY-----
APP_URL=https://your-app.lovable.app
```

**IMPORTANT**: For the private key:
1. Open the `.p8` file in a text editor
2. Copy the entire contents including `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`
3. Replace actual newlines with `\n` for the environment variable

---

## Step 2.5: Create .env File

Create a `.env` file in your project root:

```env
# Apple Sign In Configuration
VITE_APPLE_CLIENT_ID=com.yourcompany.yourapp.web
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
```

For TypeScript support, create or update `src/vite-env.d.ts`:

```typescript
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APPLE_CLIENT_ID: string;
  readonly VITE_SUPABASE_URL: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
```

> **Lovable Users**: Set these in Project Settings → Environment Variables instead of a `.env` file.

---

## Step 3: Install Dependencies

```bash
npm install despia-native
```

> Note: `jose` is only needed in the edge function (Deno), not in the frontend.

---

## Step 4: Create Apple Auth Callback Edge Function

File: `supabase/functions/auth-apple-callback/index.ts`

This edge function:
1. Receives POST from Apple (form_post response mode)
2. Verifies the Apple identity token using Apple's public keys
3. Creates or finds the Supabase user
4. Generates a real Supabase session using magic link + OTP verification
5. Redirects to frontend with tokens in URL

```typescript
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts";

// Cache for Apple's public keys
let applePublicKeys: jose.JWTVerifyGetKey | null = null;
let keysLastFetched = 0;
const KEYS_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours

async function getApplePublicKeys(): Promise<jose.JWTVerifyGetKey> {
  const now = Date.now();
  
  if (applePublicKeys && (now - keysLastFetched) < KEYS_CACHE_DURATION) {
    return applePublicKeys;
  }

  applePublicKeys = jose.createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
  keysLastFetched = now;
  
  return applePublicKeys;
}

async function verifyAppleToken(idToken: string, clientId: string): Promise<jose.JWTPayload> {
  const JWKS = await getApplePublicKeys();
  
  const { payload } = await jose.jwtVerify(idToken, JWKS, {
    issuer: 'https://appleid.apple.com',
    audience: clientId,
  });
  
  return payload;
}

function redirectWithError(appUrl: string, error: string, isNative: boolean, deeplinkScheme?: string): Response {
  const errorEncoded = encodeURIComponent(error);
  
  if (isNative && deeplinkScheme) {
    // Native: redirect to deeplink to close ASWebAuthenticationSession
    return new Response(null, {
      status: 302,
      headers: { 'Location': `${deeplinkScheme}://oauth/auth?error=${errorEncoded}` }
    });
  }
  
  // Web: redirect to frontend auth page with error
  return new Response(null, {
    status: 302,
    headers: { 'Location': `${appUrl}/auth?error=${errorEncoded}` }
  });
}

serve(async (req) => {
  const appUrl = Deno.env.get('APP_URL')!;
  const clientId = Deno.env.get('APPLE_CLIENT_ID')!;
  const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
  const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
  const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!;

  // Apple sends POST with form data (response_mode: form_post)
  if (req.method !== 'POST') {
    return new Response('Method not allowed', { status: 405 });
  }

  try {
    // Parse form data from Apple
    const formData = await req.formData();
    const idToken = formData.get('id_token') as string;
    const code = formData.get('code') as string;
    const state = formData.get('state') as string;
    const userJson = formData.get('user') as string; // Only on first auth
    const errorParam = formData.get('error') as string;

    // Parse state to check if native flow
    // State format: "uuid" for web, "uuid|deeplinkScheme" for native
    let isNative = false;
    let deeplinkScheme: string | undefined;
    
    if (state && state.includes('|')) {
      const parts = state.split('|');
      isNative = true;
      deeplinkScheme = parts[1];
    }

    // Handle Apple errors
    if (errorParam) {
      console.error('Apple auth error:', errorParam);
      return redirectWithError(appUrl, errorParam, isNative, deeplinkScheme);
    }

    if (!idToken) {
      return redirectWithError(appUrl, 'No identity token received', isNative, deeplinkScheme);
    }

    console.log('Step 1: Verifying Apple identity token...');

    // ============================================
    // STEP 1: Verify Apple identity token
    // ============================================
    let tokenPayload: jose.JWTPayload;
    
    try {
      tokenPayload = await verifyAppleToken(idToken, clientId);
    } catch (verifyError) {
      console.error('Token verification failed:', verifyError);
      return redirectWithError(appUrl, 'Invalid identity token', isNative, deeplinkScheme);
    }

    // Extract user info from token
    const appleUserId = tokenPayload.sub as string;
    const email = tokenPayload.email as string | undefined;
    const emailVerified = tokenPayload.email_verified as boolean | undefined;
    const isPrivateEmail = tokenPayload.is_private_email as boolean | string | undefined;

    console.log('Step 2: Processing user info...');

    // ============================================
    // STEP 2: Process user info
    // ============================================
    // Apple only sends user info (name) on FIRST authorization
    
    let displayName = 'Apple User';
    let firstName = '';
    let lastName = '';
    
    // Parse user JSON if provided (first sign-in only)
    if (userJson) {
      try {
        const userData = JSON.parse(userJson);
        if (userData.name) {
          firstName = userData.name.firstName || '';
          lastName = userData.name.lastName || '';
          displayName = [firstName, lastName].filter(Boolean).join(' ') || 'Apple User';
        }
      } catch (e) {
        console.warn('Failed to parse user JSON:', e);
      }
    }

    // Generate a deterministic email for Supabase if user hid their email
    const userEmail = email || `${appleUserId}@apple.oauth`;

    console.log('Step 3: Creating/finding Supabase user...');

    // ============================================
    // STEP 3: Create or find Supabase user
    // ============================================
    
    const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey, {
      auth: { autoRefreshToken: false, persistSession: false }
    });

    const supabasePublic = createClient(supabaseUrl, anonKey);

    let userId: string;

    // Try to create user first
    const { data: createData, error: createError } = await supabaseAdmin.auth.admin.createUser({
      email: userEmail,
      email_confirm: true,  // CRITICAL: Auto-confirm for OAuth users
      user_metadata: {
        apple_user_id: appleUserId,
        display_name: displayName,
        full_name: displayName,
        first_name: firstName,
        last_name: lastName,
        email_verified: emailVerified,
        is_private_email: isPrivateEmail,
        provider: 'apple'
      }
    });

    if (createError) {
      if (createError.message?.includes('already been registered')) {
        console.log('User exists, fetching...');
        const { data: existingUsers } = await supabaseAdmin.auth.admin.listUsers();
        const existingUser = existingUsers?.users.find(u => u.email === userEmail);
        
        if (!existingUser) {
          const userByAppleId = existingUsers?.users.find(
            u => u.user_metadata?.apple_user_id === appleUserId
          );
          
          if (!userByAppleId) {
            return redirectWithError(appUrl, 'User not found', isNative, deeplinkScheme);
          }
          
          userId = userByAppleId.id;
        } else {
          userId = existingUser.id;
        }

        // Update user metadata if we have new name info
        if (firstName || lastName) {
          await supabaseAdmin.auth.admin.updateUserById(userId, {
            user_metadata: {
              apple_user_id: appleUserId,
              display_name: displayName,
              full_name: displayName,
              first_name: firstName,
              last_name: lastName,
              provider: 'apple'
            }
          });
        }
      } else {
        console.error('Failed to create user:', createError);
        return redirectWithError(appUrl, 'Failed to create user', isNative, deeplinkScheme);
      }
    } else {
      userId = createData.user.id;
    }

    console.log('Step 4: Generating Supabase session via magic link...');

    // ============================================
    // STEP 4: Generate real Supabase session
    // ============================================

    const { data: linkData, error: linkError } = await supabaseAdmin.auth.admin.generateLink({
      type: 'magiclink',
      email: userEmail,
    });

    if (linkError || !linkData) {
      console.error('Failed to generate magic link:', linkError);
      return redirectWithError(appUrl, 'Failed to generate session', isNative, deeplinkScheme);
    }

    console.log('Step 5: Verifying OTP to create session...');

    const { data: sessionData, error: sessionError } = await supabasePublic.auth.verifyOtp({
      token_hash: linkData.properties.hashed_token,
      type: 'email',
    });

    if (sessionError || !sessionData.session) {
      console.error('Failed to verify OTP:', sessionError);
      return redirectWithError(appUrl, 'Failed to create session', isNative, deeplinkScheme);
    }

    console.log('Success! User logged in:', userId);

    // ============================================
    // STEP 5: Redirect to frontend with tokens
    // ============================================
    const accessToken = sessionData.session.access_token;
    const refreshToken = sessionData.session.refresh_token;

    const params = new URLSearchParams({
      access_token: accessToken,
      refresh_token: refreshToken,
    });

    if (isNative && deeplinkScheme) {
      // Native: redirect to deeplink to close ASWebAuthenticationSession
      // The deeplink will navigate the WebView to /auth with tokens
      return new Response(null, {
        status: 302,
        headers: { 'Location': `${deeplinkScheme}://oauth/auth?${params.toString()}` }
      });
    }

    // Web: redirect directly to frontend auth page
    return new Response(null, {
      status: 302,
      headers: { 'Location': `${appUrl}/auth?${params.toString()}` }
    });

  } catch (error) {
    console.error('Error:', error);
    const message = error instanceof Error ? error.message : 'Unknown error';
    
    // Can't determine native state, default to web redirect
    return new Response(null, {
      status: 302,
      headers: { 'Location': `${appUrl}/auth?error=${encodeURIComponent(message)}` }
    });
  }
});
```

---

## Step 6: Configure Edge Function

File: `supabase/config.toml`

```toml
[functions.auth-apple-callback]
verify_jwt = false
```

---

## Step 7: Create Apple Auth Helper

File: `src/lib/apple-auth.ts`

> ⚠️ **AI CODING AGENTS: ENVIRONMENT VARIABLES REQUIRED**
> 
> This file reads configuration from environment variables. Make sure to set:
> 
> - `VITE_APPLE_CLIENT_ID`: Your Apple Service ID from Apple Developer Portal
> - `VITE_SUPABASE_URL`: Your Supabase project URL
> 
> In `.env` file:
> ```env
> VITE_APPLE_CLIENT_ID=com.mycompany.myapp.web
> VITE_SUPABASE_URL=https://your-project-ref.supabase.co
> ```
> 
> **If not set, you'll see console warnings**: `⚠️ Set VITE_APPLE_CLIENT_ID in .env to enable Apple Sign In`

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

// ============================================
// CONFIGURATION - Set these in your .env file
// ============================================
const APPLE_CLIENT_ID = import.meta.env.VITE_APPLE_CLIENT_ID || '';
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || '';

// Warn if not configured
if (!APPLE_CLIENT_ID) {
  console.warn('⚠️ Set `VITE_APPLE_CLIENT_ID` in .env to enable Apple Sign In');
}
if (!SUPABASE_URL) {
  console.warn('⚠️ Set `VITE_SUPABASE_URL` in .env to enable Apple Sign In');
}
// ============================================

/**
 * Get the Apple OAuth redirect URL
 * Apple will POST to our edge function, which then redirects to frontend
 */
function getRedirectUri(): string {
  return `${SUPABASE_URL}/functions/v1/auth-apple-callback`;
}

/**
 * Start Apple Sign In flow (redirect-based)
 * 
 * Uses response_mode: form_post because Apple requires this when
 * requesting name or email scopes. Apple will POST the response
 * to our edge function.
 */
export function startAppleSignIn(isNative: boolean = false, deeplinkScheme: string = 'myapp'): void {
  if (!APPLE_CLIENT_ID || !SUPABASE_URL) {
    console.error('Apple Sign In not configured. Set VITE_APPLE_CLIENT_ID and VITE_SUPABASE_URL.');
    return;
  }

  // Generate state - for native, include deeplink scheme
  const state = isNative 
    ? `${crypto.randomUUID()}|${deeplinkScheme}`
    : crypto.randomUUID();

  const params = new URLSearchParams({
    client_id: APPLE_CLIENT_ID,
    response_type: 'code id_token',
    response_mode: 'form_post', // Required for name/email scopes
    scope: 'name email',
    redirect_uri: getRedirectUri(),
    state: state,
  });

  const appleAuthUrl = `https://appleid.apple.com/auth/authorize?${params.toString()}`;

  if (isNative) {
    // Native: use Despia to open ASWebAuthenticationSession
    // This import should be at the top of your LoginButton component
    // despia(`oauth://?url=${encodeURIComponent(appleAuthUrl)}`);
    window.location.href = appleAuthUrl; // Fallback - actual native uses despia()
  } else {
    // Web: redirect to Apple
    window.location.href = appleAuthUrl;
  }
}

/**
 * Get the Apple OAuth URL (for native Despia flow)
 */
export function getAppleOAuthUrl(isNative: boolean = false, deeplinkScheme: string = 'myapp'): string {
  if (!APPLE_CLIENT_ID || !SUPABASE_URL) {
    console.error('Apple Sign In not configured');
    return '';
  }

  const state = isNative 
    ? `${crypto.randomUUID()}|${deeplinkScheme}`
    : crypto.randomUUID();

  const params = new URLSearchParams({
    client_id: APPLE_CLIENT_ID,
    response_type: 'code id_token',
    response_mode: 'form_post',
    scope: 'name email',
    redirect_uri: getRedirectUri(),
    state: state,
  });

  return `https://appleid.apple.com/auth/authorize?${params.toString()}`;
}

/**
 * Set Supabase session from tokens received in URL
 */
export async function setAppleSession(
  accessToken: string,
  refreshToken: string
): Promise<boolean> {
  try {
    const { error } = await supabase.auth.setSession({
      access_token: accessToken,
      refresh_token: refreshToken,
    });

    if (error) {
      console.error('Failed to set session:', error);
      return false;
    }

    return true;
  } catch (err) {
    console.error('Error setting session:', err);
    return false;
  }
}
```

---

## Step 8: Create Login Button

File: `src/components/LoginButton.tsx`

```typescript
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import despia from 'despia-native';
import { startAppleSignIn, getAppleOAuthUrl } from '@/lib/apple-auth';

interface LoginButtonProps {
  onError?: (error: string) => void;
  deeplinkScheme?: string; // Your app's deeplink scheme for native
}

const LoginButton = ({ onError, deeplinkScheme = 'myapp' }: LoginButtonProps) => {
  const [isLoading, setIsLoading] = useState(false);

  // Detect if running in Despia native app
  const isNative = typeof navigator !== 'undefined' && 
    navigator.userAgent.toLowerCase().includes('despia');

  const handleAppleLogin = () => {
    setIsLoading(true);

    try {
      if (isNative) {
        // NATIVE FLOW
        // Use Despia to open ASWebAuthenticationSession
        const oauthUrl = getAppleOAuthUrl(true, deeplinkScheme);
        
        if (!oauthUrl) {
          onError?.('Apple Sign In not configured');
          setIsLoading(false);
          return;
        }
        
        // oauth:// prefix tells Despia to use ASWebAuthenticationSession
        despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`);
        // Note: Loading state will persist until user returns or cancels
      } else {
        // WEB FLOW
        // Simple redirect to Apple - they POST back to our edge function
        startAppleSignIn(false);
        // Page will redirect, loading state doesn't matter
      }
    } catch (err) {
      console.error('Login error:', err);
      onError?.(err instanceof Error ? err.message : 'Login failed');
      setIsLoading(false);
    }
  };

  return (
    <Button
      onClick={handleAppleLogin}
      disabled={isLoading}
      className="w-full bg-black hover:bg-gray-800 text-white"
    >
      {isLoading ? (
        <span className="flex items-center gap-2">
          <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
            <circle
              className="opacity-25"
              cx="12"
              cy="12"
              r="10"
              stroke="currentColor"
              strokeWidth="4"
              fill="none"
            />
            <path
              className="opacity-75"
              fill="currentColor"
              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
            />
          </svg>
          Connecting...
        </span>
      ) : (
        <span className="flex items-center gap-2">
          {/* Apple Icon */}
          <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
            <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
          </svg>
          Sign in with Apple
        </span>
      )}
    </Button>
  );
};

export default LoginButton;
```

---

## Step 9: Create Auth Page

File: `src/pages/Auth.tsx`

This page:
1. Receives tokens from URL query params (from edge function redirect)
2. Sets the Supabase session
3. Redirects to home or shows login button

```typescript
import { useEffect, useState, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { Button } from '@/components/ui/button';
import LoginButton from '@/components/LoginButton';
import { setAppleSession } from '@/lib/apple-auth';

const Auth = () => {
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const [error, setError] = useState<string | null>(null);
  const [showLogin, setShowLogin] = useState(false);
  const [isProcessing, setIsProcessing] = useState(false);
  const hasRun = useRef(false);

  useEffect(() => {
    if (hasRun.current) return;
    hasRun.current = true;

    const handleAuth = async () => {
      // Check if already logged in
      const { data: { session: existingSession } } = await supabase.auth.getSession();
      if (existingSession) {
        navigate('/', { replace: true });
        return;
      }

      // Check for error from edge function redirect
      const errorParam = searchParams.get('error');
      if (errorParam) {
        setError(decodeURIComponent(errorParam));
        return;
      }

      // Check for tokens from edge function redirect
      const accessToken = searchParams.get('access_token');
      const refreshToken = searchParams.get('refresh_token');

      if (accessToken && refreshToken) {
        setIsProcessing(true);
        
        const success = await setAppleSession(accessToken, refreshToken);
        
        if (success) {
          // Clear tokens from URL for security
          window.history.replaceState({}, '', '/auth');
          navigate('/', { replace: true });
        } else {
          setError('Failed to set session');
          setIsProcessing(false);
        }
        return;
      }

      // No tokens - show login button
      setShowLogin(true);
    };

    handleAuth();
  }, [searchParams, navigate]);

  if (error) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-background p-4">
        <div className="w-full max-w-sm space-y-4">
          <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-center">
            <h2 className="text-lg font-semibold text-destructive">Sign in failed</h2>
            <p className="mt-2 text-sm text-muted-foreground">{error}</p>
          </div>
          <Button onClick={() => { setError(null); setShowLogin(true); }} className="w-full">
            Try again
          </Button>
          <Button 
            variant="outline" 
            onClick={() => navigate('/', { replace: true })} 
            className="w-full"
          >
            Go to home
          </Button>
        </div>
      </div>
    );
  }

  if (showLogin) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-background p-4">
        <div className="w-full max-w-sm space-y-6 text-center">
          <div>
            <h1 className="text-2xl font-bold">Welcome</h1>
            <p className="mt-2 text-muted-foreground">Sign in to continue</p>
          </div>
          <LoginButton onError={setError} />
        </div>
      </div>
    );
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-background p-4">
      <div className="text-center">
        <div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
        <p className="mt-4 text-sm text-muted-foreground">
          {isProcessing ? 'Completing sign in...' : 'Loading...'}
        </p>
      </div>
    </div>
  );
};

export default Auth;
```

---

## Step 10: Add Routes to App.tsx

File: `src/App.tsx`

```typescript
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Index from './pages/Index';
import Auth from './pages/Auth';
import NotFound from './pages/NotFound';
import SpaRedirector from './components/SpaRedirector';

function App() {
  return (
    <BrowserRouter>
      <SpaRedirector />
      <Routes>
        <Route path="/" element={<Index />} />
        <Route path="/auth" element={<Auth />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;
```

---

## Step 11: Create SPA Fallback Files

File: `src/components/SpaRedirector.tsx`

```typescript
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";

export default function SpaRedirector() {
  const location = useLocation();
  const navigate = useNavigate();

  useEffect(() => {
    const params = new URLSearchParams(location.search);
    const redirect = params.get("redirect");
    
    if (!redirect) return;

    try {
      const decoded = decodeURIComponent(redirect);
      // Preserve hash for Apple's fragment response
      navigate(`${decoded}${location.hash ?? ""}`, { replace: true });
    } catch {
      console.error('Failed to decode redirect path');
    }
  }, [location.search, location.hash, navigate]);

  return null;
}
```

File: `public/404.html`

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Redirecting</title>
  </head>
  <body>
    <script>
      (function () {
        // Preserve the full path including query params AND hash (for Apple's fragment response)
        var redirect = window.location.pathname + window.location.search;
        var hash = window.location.hash || '';
        var target = "/?redirect=" + encodeURIComponent(redirect) + hash;
        window.location.replace(target);
      })();
    </script>
  </body>
</html>
```

File: `public/_redirects`

```
/* /index.html 200
```

---

## Step 12: Configuration Summary

> ⚠️ **CRITICAL: Set Environment Variables**
> 
> If you see console warnings like:
> - `⚠️ Set VITE_APPLE_CLIENT_ID in .env to enable Apple Sign In`
> - `⚠️ Set VITE_SUPABASE_URL in .env to enable Apple Sign In`
>
> **You MUST set these environment variables before Apple Sign In will work!**

### Frontend (.env file)

Create a `.env` file in your project root (or set in Lovable's Environment Variables):

```env
VITE_APPLE_CLIENT_ID=com.yourcompany.yourapp.web
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
```

### Backend (Supabase Edge Function Secrets)

Set these in Supabase Dashboard > Project Settings > Edge Functions > Secrets:

```
APPLE_CLIENT_ID=com.yourcompany.yourapp.web
APPLE_TEAM_ID=ABCD1234EF
APPLE_KEY_ID=ABC123DEFG
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIGT...key...\n-----END PRIVATE KEY-----
APP_URL=https://your-app.lovable.app
```

---

## Step 13: Using Login State in Your App

File: `src/pages/Index.tsx` (example)

```typescript
import { useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { User } from '@supabase/supabase-js';
import LoginButton from '@/components/LoginButton';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';

const Index = () => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setUser(session?.user ?? null);
      setLoading(false);
    });

    // Listen for auth changes
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (event, session) => {
        setUser(session?.user ?? null);
      }
    );

    return () => subscription.unsubscribe();
  }, []);

  const handleSignOut = async () => {
    await supabase.auth.signOut();
  };

  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center">
        <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
      </div>
    );
  }

  if (!user) {
    return (
      <div className="flex min-h-screen items-center justify-center p-4">
        <div className="w-full max-w-sm space-y-6 text-center">
          <h1 className="text-2xl font-bold">Welcome</h1>
          <p className="text-muted-foreground">Sign in with Apple to continue</p>
          <LoginButton />
        </div>
      </div>
    );
  }

  // User is logged in
  const metadata = user.user_metadata;

  return (
    <div className="flex min-h-screen items-center justify-center p-4">
      <div className="w-full max-w-sm space-y-6 text-center">
        <Avatar className="mx-auto h-20 w-20">
          <AvatarFallback>
            {metadata.display_name?.charAt(0) || metadata.first_name?.charAt(0) || 'U'}
          </AvatarFallback>
        </Avatar>
        
        <div>
          <h1 className="text-2xl font-bold">
            {metadata.display_name || metadata.full_name || 'Apple User'}
          </h1>
          {metadata.is_private_email && (
            <p className="text-xs text-muted-foreground mt-1">
              Using private email relay
            </p>
          )}
        </div>

        <Button onClick={handleSignOut} variant="outline" className="w-full">
          Sign Out
        </Button>
      </div>
    </div>
  );
};

export default Index;
```

---

## Configuration Checklist

### Apple Developer Portal

- [ ] Created App ID with Sign In with Apple capability
- [ ] Created Service ID for web authentication
- [ ] Configured Service ID with:
  - Domain: `your-app.lovable.app` and `your-project-ref.supabase.co`
  - Return URL: `https://your-project-ref.supabase.co/functions/v1/auth-apple-callback`
- [ ] Created Sign In with Apple Key
- [ ] Downloaded `.p8` key file (one-time download!)
- [ ] Noted Team ID, Service ID, and Key ID

### Frontend Configuration (.env file)

- [ ] Set `VITE_APPLE_CLIENT_ID` in .env to your Service ID
- [ ] Set `VITE_SUPABASE_URL` in .env to your Supabase project URL

### Supabase

- [ ] Added edge function secrets:
  - `APPLE_CLIENT_ID`
  - `APPLE_TEAM_ID`
  - `APPLE_KEY_ID`
  - `APPLE_PRIVATE_KEY`
  - `APP_URL`
- [ ] Deployed `auth-apple-callback` edge function
- [ ] Set `verify_jwt = false` in config.toml

### Despia (for native apps)

- [ ] Set deeplink scheme (e.g., `myapp`) in LoginButton.tsx

### App Code

- [ ] All files created and added to project
- [ ] Routes added to `App.tsx`

---

## Update Checklist

> 🚨 **MANDATORY: These values MUST be set or Apple Sign In will not work!**

Set these environment variables:

| Location | Variable | Value | If Not Set |
|----------|----------|-------|------------|
| .env file | `VITE_APPLE_CLIENT_ID` | Your Apple Service ID | ⚠️ Console warning, auth fails |
| .env file | `VITE_SUPABASE_URL` | Your Supabase project URL | ⚠️ Console warning, auth fails |
| Supabase secrets | `APP_URL` | Your frontend app URL | ❌ Wrong redirect after auth |
| LoginButton.tsx | `deeplinkScheme` prop | Your Despia deeplink scheme | ❌ Native login fails |

---

## How It All Connects

```
WEB FLOW:
LoginButton -> window.location.href = Apple OAuth URL
-> User authenticates at Apple
-> Apple POSTs to edge function (form_post)
-> Edge function verifies token, creates session
-> Edge function redirects to /auth?access_token=xxx
-> Auth.tsx calls setSession() -> Logged in

NATIVE FLOW:
LoginButton -> despia('oauth://?url=...') 
-> ASWebAuthenticationSession opens -> Apple native auth
-> Apple POSTs to edge function
-> myapp://oauth/auth?tokens (deeplink closes browser)
-> /auth?tokens -> setSession() -> Logged in
```

---

## Key Differences from other OAuth Flows

| Aspect | Others | Apple |
|--------|--------|-------|
| **SDK** | Manual OAuth URL | Manual OAuth URL (redirect) |
| **Response mode** | Query params / fragment | form_post (required for name/email) |
| **Token type** | Authorization code | Identity token (JWT) |
| **Verification** | Exchange code for token | Verify JWT with Apple's public keys |
| **User info** | Fetch from API | Decoded from token + first-time user object |
| **Email** | Always provided | User can hide (private relay) |
| **Name** | From API | Only on FIRST authorization |
| **Callback** | Frontend receives params | Edge function receives POST |

---

## Troubleshooting

| Problem | Solution |
|---------|----------|
| **⚠️ Console warning about VITE_APPLE_CLIENT_ID** | Set `VITE_APPLE_CLIENT_ID` in your `.env` file with your Apple Service ID |
| **⚠️ Console warning about VITE_SUPABASE_URL** | Set `VITE_SUPABASE_URL` in your `.env` file with your Supabase URL |
| "Invalid client_id" | Check `VITE_APPLE_CLIENT_ID` matches your Service ID exactly |
| "Invalid redirect_uri" | Add exact edge function URL to Apple Developer Portal Service ID config |
| "Token verification failed" | Check token hasn't expired (5 min lifetime) |
| "bad_jwt" error | You're using manual JWT - use magic link + verifyOtp instead |
| User name not showing | Apple only sends name on FIRST authorization - name is stored in user_metadata |
| Email is `xxx@privaterelay.appleid.com` | User chose to hide email - this is expected behavior |
| Native browser doesn't close | Use `myapp://oauth/...` format (oauth/ prefix required) |
| Wrong redirect after auth | Check `APP_URL` secret is set correctly in Supabase |

---

## Security Considerations

1. **Token Verification**: Apple identity tokens are verified against Apple's public keys (JWKS)
2. **Audience Validation**: Token audience must match your client ID
3. **Issuer Validation**: Token issuer must be `https://appleid.apple.com`
4. **Token Expiry**: Apple tokens expire after 5 minutes - verify immediately
5. **Private Key Security**: Never expose `.p8` key contents to frontend
6. **State Parameter**: Prevents CSRF attacks in native flow
7. **form_post**: Apple POSTs to server, not to client - credentials never in browser URL

---

## Apple-Specific Notes

### Private Email Relay

When users choose "Hide My Email", Apple creates a unique private relay address like `abc123@privaterelay.appleid.com`. This email:
- Is unique per user per app
- Forwards to user's real email
- Can receive emails from your app
- User can disable forwarding anytime

### User Info Availability

Apple only provides user info (name, email) on the **first** authorization. After that:
- `id_token` always contains: `sub` (user ID), `email` (if shared)
- `user` object is null on subsequent logins
- Store user info on first login!

### Token Refresh

Apple doesn't provide refresh tokens in the same way as other OAuth providers. The identity token is short-lived (5 min) and only used for initial authentication. After that, you use Supabase's session management.

---

## Files Summary

| File | Purpose |
|------|---------|
| .env | Frontend environment variables (VITE_APPLE_CLIENT_ID, VITE_SUPABASE_URL) |
| src/vite-env.d.ts | TypeScript types for environment variables |
| supabase/functions/auth-apple-callback/index.ts | Edge function - receives Apple POST, redirects to frontend |
| supabase/config.toml | Edge function configuration |
| src/lib/apple-auth.ts | Client-side auth helpers |
| src/components/LoginButton.tsx | Login button - redirects to Apple |
| src/pages/Auth.tsx | Receives tokens from URL, sets session |
| src/App.tsx | Route configuration |
| src/components/SpaRedirector.tsx | Handle 404 fallback |
| public/404.html | Preserve params on 404 |
| public/_redirects | SPA routing |

Setup Requirements

  • Create an Apple Sign In application in Apple Developer Portal

  • Configure your Supabase project with Apple credentials (custom provider - requires edge function)

  • Set up your Despia project with a deeplink scheme

Important: This works with Lovable + Supabase projects but requires a custom edge function since Apple Sign In uses response_mode: form_post which requires server-side handling.

Understanding the Complete OAuth Flow

The Problem

When you wrap a Lovable web app as a native iOS/Android app using Despia:

  • The app runs in a WebView (embedded browser)

  • WebViews cannot handle OAuth redirects properly

  • Apple OAuth redirects back to a URL, but the WebView doesn't know what to do

  • Apple requires response_mode: form_post when requesting name/email scopes

The Solution

For both web and native apps, we:

  1. Redirect user to Apple's authorization page

  2. Apple POSTs credentials directly to our edge function (form_post)

  3. Edge function verifies the identity token and creates a Supabase session

  4. Edge function redirects to frontend with tokens (or deeplink for native)

  5. Frontend sets the session

How Native OAuth Works

Step 1: Detect if running in native app

When the user clicks "Sign in", your app first checks if it's running inside a Despia native app. Despia automatically adds despia to the userAgent string:

const isNative = navigator.userAgent.toLowerCase().includes('despia');

Step 2: Generate Apple OAuth URL

For Apple, we generate the OAuth URL on the frontend. The client ID (Service ID) is public. We use response_mode: form_post which Apple requires for name/email scopes:

// src/lib/apple-auth.ts
const APPLE_CLIENT_ID = import.meta.env.VITE_APPLE_CLIENT_ID;
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;

// Redirect URI points to edge function (Apple POSTs here)
const redirectUri = `${SUPABASE_URL}/functions/v1/auth-apple-callback`;

// For native, include deeplink scheme in state
const state = isNative 
  ? `${crypto.randomUUID()}|${deeplinkScheme}` 
  : crypto.randomUUID();

const params = new URLSearchParams({
  client_id: APPLE_CLIENT_ID,
  response_type: 'code id_token',
  response_mode: 'form_post', // Required for name/email scopes
  scope: 'name email',
  redirect_uri: redirectUri,
  state: state,
});

const oauthUrl = `https://appleid.apple.com/auth/authorize?${params}`;

Step 3: Open OAuth in secure browser session

Once you have the OAuth URL, open it using despia('oauth://?url=...'). The oauth:// prefix tells Despia to open the URL in a secure native browser session:

despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`);

This opens the URL in:

  • iOS: ASWebAuthenticationSession (secure Safari sheet)

  • Android: Chrome Custom Tabs (secure Chrome overlay)

Step 4: User completes OAuth in the browser session

  • User sees Apple Sign In inside ASWebAuthenticationSession / Chrome Custom Tab

  • User authenticates with Face ID / Touch ID / password

  • Apple POSTs id_token, code, and user data to your edge function

  • This happens server-side (not in the browser session)

Step 5: Edge function processes and redirects

Apple uses form_post which sends an identity token (JWT) directly.

The edge function:

  1. Parses form data from Apple's POST

  2. Verifies the identity token using Apple's public keys (JWKS)

  3. Creates or finds the Supabase user

  4. Generates a real Supabase session via magic link + OTP verification

  5. Redirects to frontend with tokens (or deeplink for native)

// Edge function detects native flow from state parameter
if (state?.includes('|')) {
  const deeplinkScheme = state.split('|')[1];
  // Redirect to deeplink to close browser session
  return Response.redirect(`${deeplinkScheme}://oauth/auth?access_token=${token}`);
}

// Web flow: redirect to frontend
return Response.redirect(`${APP_URL}/auth?access_token=${token}`);

For native, the edge function redirects to a deeplink which closes the browser session:

myapp://oauth/auth?access_token=xxx&refresh_token=yyy

Deeplink format: {scheme}://oauth/{path}?params

  • myapp:// - Your app's deeplink scheme

  • oauth/ - Required prefix - tells native code to close the browser session

  • {path} - Where to navigate in your app (e.g., auth)

  • ?params - Query params passed to that page

  • Native app intercepts myapp://oauth/... deeplink

  • Closes ASWebAuthenticationSession / Chrome Custom Tab

  • Navigates WebView to /{path}?params (e.g., /auth?access_token=xxx)

  • Your Auth.tsx parses tokens and sets the session:

const accessToken = searchParams.get('access_token');
const refreshToken = searchParams.get('refresh_token');

await supabase.auth.setSession({
  access_token: accessToken,
  refresh_token: refreshToken || '',
});

// User is now logged in!
navigate('/');

How The Two Flows Work

Web Browser Flow

Native App Flow (Despia WebView)

Installation

Install the Despia package from NPM:

npm install despia-native

Usage

Import the SDK:

import despia from 'despia-native';

Opening the Native OAuth Session:

// Opens OAuth URL in secure native browser session
despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`);

Closing happens automatically when the edge function redirects to your deeplink. The oauth/ prefix in the deeplink tells Despia to close the browser session.

Apple Developer Portal Setup

1. Create an App ID

  1. Go to Identifiers

  2. Create new App ID with Sign In with Apple capability

  3. Bundle ID: com.yourcompany.yourapp

2. Create a Service ID (for web)

  1. Create new Services ID

  2. Identifier: com.yourcompany.yourapp.web

  3. Configure Sign In with Apple:

    • Domains: your-app.lovable.app, your-project.supabase.co

    • Return URL: https://your-project.supabase.co/functions/v1/auth-apple-callback

3. Create a Sign In Key

  1. Go to Keys

  2. Create key with Sign In with Apple

  3. Download the .p8 file (one-time download!)

  4. Note the Key ID

4. Note Your Credentials

  • Team ID: Top right of Apple Developer portal

  • Service ID: com.yourcompany.yourapp.web

  • Key ID: From the key you created

  • Private Key: Contents of .p8 file

Environment Variables

Frontend (.env)

VITE_APPLE_CLIENT_ID=com.yourcompany.yourapp.web
VITE_SUPABASE_URL=https://your-project.supabase.co

Supabase Edge Function Secrets

APPLE_CLIENT_ID=com.yourcompany.yourapp.web
APPLE_TEAM_ID=ABCD1234EF
APPLE_KEY_ID=ABC123DEFG
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIGT...key...\n-----END PRIVATE KEY-----
APP_URL=https://your-app.lovable.app

Key Technical Notes

Why form_post?

Apple requires response_mode: form_post when requesting name or email scopes. This means:

  • Apple POSTs credentials to your server (not browser redirect)

  • Your edge function must handle the POST and redirect to frontend

  • Credentials never appear in browser URL (more secure)

User Info Only on First Auth

Apple only sends user info (name, email) on the first authorization. After that:

  • id_token contains: sub (user ID), email (if shared)

  • user object is null on subsequent logins

  • Store user info on first login!

Private Email Relay

When users choose "Hide My Email", Apple creates abc123@privaterelay.appleid.com. This:

  • Is unique per user per app

  • Forwards to user's real email

  • User can disable forwarding anytime

Resources

Need Help?

For additional support or questions, please contact our support team at support@despia.com

Updated on