Copy
Ask AI
# Apple Sign In - Complete Platform-Aware Implementation
## Platform Strategy
**iOS (Despia)**: Apple JS SDK -> Native Face ID dialog (instant)
**Android (Despia)**: oauth:// protocol -> ASWebAuthenticationSession -> form_post
**Web**: Apple JS SDK -> Native browser dialog (instant)
**Why Different Approaches**:
- iOS has native Apple Sign In support -> Use JS SDK for instant dialog
- Android has NO native Apple Sign In -> Use oauth:// to trigger browser session
- Web has Apple Sign In support -> Use JS SDK for instant dialog
## Apple Developer Console Setup
### Required Links to Register
You need to register these URLs in your Apple Developer Console Service ID configuration:
**Domains**:
- Your app domain: `your-app.lovable.app` (or your custom domain)
- Supabase function domain: `your-project-ref.supabase.co`
**Return URLs**:
- Edge function callback: `https://your-project-ref.supabase.co/functions/v1/auth-apple-callback`
- App callback (for JS SDK): `https://your-app.lovable.app/auth/apple/callback`
### Step-by-Step Guide
#### 1. Create App ID
1. Go to https://developer.apple.com/account/resources/identifiers/list
2. Click the **+** button
3. Select **App IDs** → **Continue**
4. Select **App** type → **Continue**
5. Fill in:
- **Description**: Your app name (e.g., "My Awesome App")
- **Bundle ID**: Explicit (e.g., `com.yourcompany.yourapp`)
6. Under **Capabilities**, check **Sign In with Apple**
7. Click **Continue** → **Register**
#### 2. Create Service ID
1. Go to https://developer.apple.com/account/resources/identifiers/list
2. Click the **+** button
3. Select **Services IDs** → **Continue**
4. Fill in:
- **Description**: "Your App Web Auth"
- **Identifier**: `com.yourcompany.yourapp.web` (MUST be different from App ID)
5. Click **Continue** → **Register**
#### 3. Configure Service ID
1. Click on your newly created Service ID
2. Check **Sign In with Apple**
3. Click **Configure** next to Sign In with Apple
4. In the configuration screen:
**Primary App ID**: Select your App ID from step 1
**Domains and Subdomains** - Add both:
```
your-app.lovable.app
your-project-ref.supabase.co
```
**Return URLs** - Add both:
```
https://your-project-ref.supabase.co/functions/v1/auth-apple-callback
https://your-app.lovable.app/auth/apple/callback
```
5. Click **Save**
6. Click **Continue** → **Save**
**Important Notes**:
- The first Return URL is for Android (receives form_post from oauth:// flow)
- The second Return URL is for JS SDK initialization (iOS/Web)
- Both domains must be registered even though JS SDK uses different flow
- No `www` prefix unless your actual domain uses it
#### 4. Create Sign In 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**
5. Click **Configure** next to Sign In with Apple
6. Select your **Primary App ID** → **Save**
7. Click **Continue** → **Register**
8. **CRITICAL**: Download the `.p8` file (you can only download once!)
9. Note the **Key ID** shown on screen
#### 5. Note Your Credentials
You'll need these values for environment variables:
- **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
### Verification Checklist
Before proceeding, verify:
- [ ] Service ID created and configured
- [ ] Both domains added (app domain + Supabase domain)
- [ ] Both return URLs added (edge function + app callback)
- [ ] Sign In key created and `.p8` file downloaded
- [ ] Team ID, Service ID, and Key ID noted
- [ ] `.p8` file contents saved securely
## Implementation
### 1. Load Apple JS SDK in index.html
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Your App</title>
<!-- Apple Sign In JS SDK (for iOS and Web) -->
<script
type="text/javascript"
src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"
></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
### 2. Apple Auth Library with Platform Detection
```typescript
// src/lib/apple-auth.ts
import { supabase } from '@/integrations/supabase/client';
const APPLE_CLIENT_ID = import.meta.env.VITE_APPLE_CLIENT_ID || '';
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || '';
const APP_URL = import.meta.env.VITE_APP_URL || window.location.origin;
if (!APPLE_CLIENT_ID) console.warn('Set VITE_APPLE_CLIENT_ID in .env');
if (!SUPABASE_URL) console.warn('Set VITE_SUPABASE_URL in .env');
// Platform detection
export function detectPlatform(): 'ios' | 'android' | 'web' {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('despia-iphone') || ua.includes('despia-ipad')) return 'ios';
if (ua.includes('despia-android')) return 'android';
return 'web';
}
// Initialize Apple ID (for iOS and Web only)
export function initAppleAuth() {
const platform = detectPlatform();
// Only initialize for iOS and Web (Android uses oauth:// flow)
if (platform === 'android') return;
if (!APPLE_CLIENT_ID) return;
if (typeof window !== 'undefined' && (window as any).AppleID) {
(window as any).AppleID.auth.init({
clientId: APPLE_CLIENT_ID,
scope: 'name email',
redirectURI: `${APP_URL}/auth/apple/callback`,
usePopup: false,
});
}
}
// iOS/Web: Sign in with Apple JS SDK
export async function signInWithAppleJS(): Promise<{
idToken: string;
code: string;
user?: any;
}> {
if (typeof window === 'undefined' || !(window as any).AppleID) {
throw new Error('Apple ID not initialized');
}
try {
const response = await (window as any).AppleID.auth.signIn();
return {
idToken: response.authorization.id_token,
code: response.authorization.code,
user: response.user,
};
} catch (error: any) {
if (error.error === 'popup_closed_by_user') {
throw new Error('Sign in cancelled');
}
throw error;
}
}
// Android: Sign in with oauth:// protocol
export function signInWithOAuthProtocol(deeplinkScheme: string = 'myapp'): void {
if (!APPLE_CLIENT_ID || !SUPABASE_URL) {
throw new Error('Apple Sign In not configured');
}
const state = `${crypto.randomUUID()}|android|${deeplinkScheme}`;
const params = new URLSearchParams({
client_id: APPLE_CLIENT_ID,
response_type: 'code id_token',
response_mode: 'form_post',
scope: 'name email',
redirect_uri: `${SUPABASE_URL}/functions/v1/auth-apple-callback`,
state: state,
});
const appleAuthUrl = `https://appleid.apple.com/auth/authorize?${params.toString()}`;
// Use oauth:// protocol to trigger ASWebAuthenticationSession
window.location.href = `oauth://?url=${encodeURIComponent(appleAuthUrl)}`;
}
// Set session from tokens
export async function setAppleSession(
accessToken: string,
refreshToken: string
): Promise<boolean> {
try {
const { error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
});
return !error;
} catch {
return false;
}
}
```
### 3. LoginButton with Platform Detection
```typescript
// src/components/LoginButton.tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
detectPlatform,
signInWithAppleJS,
signInWithOAuthProtocol
} from '@/lib/apple-auth';
interface LoginButtonProps {
onError?: (error: string) => void;
deeplinkScheme?: string;
}
const LoginButton = ({ onError, deeplinkScheme = 'myapp' }: LoginButtonProps) => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const handleAppleLogin = async () => {
setIsLoading(true);
try {
const platform = detectPlatform();
if (platform === 'android') {
// Android: Use oauth:// protocol
// This will redirect, so loading state doesn't matter
signInWithOAuthProtocol(deeplinkScheme);
} else {
// iOS/Web: Use Apple JS SDK
const { idToken, code, user } = await signInWithAppleJS();
// Navigate to loading page with credentials
navigate('/auth-loading', {
state: { idToken, code, user, platform },
replace: true,
});
}
} catch (err) {
console.error('Apple Sign In error:', err);
onError?.(err instanceof Error ? err.message : 'Sign in 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">
<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;
```
### 4. AuthLoading Page (Handles JS SDK Response)
```typescript
// src/pages/AuthLoading.tsx
import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { setAppleSession } from '@/lib/apple-auth';
const AuthLoading = () => {
const navigate = useNavigate();
const location = useLocation();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const processAppleAuth = async () => {
const { idToken, code, user, platform } = location.state || {};
if (!idToken) {
navigate('/auth?error=Missing+credentials', { replace: true });
return;
}
try {
// Send to edge function for verification
const response = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/auth-apple-callback`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id_token: idToken,
code,
user: user ? JSON.stringify(user) : null,
platform: platform || 'web',
}),
}
);
const data = await response.json();
if (data.error) {
setError(data.error);
setTimeout(() => {
navigate(`/auth?error=${encodeURIComponent(data.error)}`, { replace: true });
}, 2000);
return;
}
if (data.access_token && data.refresh_token) {
const success = await setAppleSession(data.access_token, data.refresh_token);
if (success) {
navigate('/', { replace: true });
} else {
setError('Failed to set session');
setTimeout(() => {
navigate('/auth?error=Session+failed', { replace: true });
}, 2000);
}
}
} catch (err) {
console.error('Auth processing error:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setTimeout(() => {
navigate('/auth?error=Processing+failed', { replace: true });
}, 2000);
}
};
processAppleAuth();
}, [location.state, navigate]);
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-purple-600 to-purple-800">
<div className="text-center p-8">
<svg className="w-16 h-16 mx-auto mb-6 animate-spin" viewBox="0 0 50 50">
<circle className="opacity-25" cx="25" cy="25" r="20" stroke="white" strokeWidth="3" fill="none"/>
<path className="opacity-75" fill="white" d="M25 5 A 20 20 0 0 1 45 25"/>
</svg>
<h1 className="text-2xl font-semibold text-white mb-2">
{error ? 'Sign in failed' : 'Signing you in'}
</h1>
<p className="text-white/80 text-sm">
{error || 'Please wait a moment...'}
</p>
</div>
</div>
);
};
export default AuthLoading;
```
### 5. Auth Page (Handles Android Redirect)
```typescript
// src/pages/Auth.tsx
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 () => {
const { data: { session: existingSession } } = await supabase.auth.getSession();
if (existingSession) {
navigate('/', { replace: true });
return;
}
const errorParam = searchParams.get('error');
if (errorParam) {
setError(decodeURIComponent(errorParam));
return;
}
// Check for tokens (from Android oauth:// 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) {
window.history.replaceState({}, '', '/auth');
navigate('/', { replace: true });
} else {
setError('Failed to set session');
setIsProcessing(false);
}
return;
}
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>
</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;
```
### 6. Initialize Apple Auth
```typescript
// src/App.tsx
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Index from './pages/Index';
import Auth from './pages/Auth';
import AuthLoading from './pages/AuthLoading';
import { initAppleAuth } from './lib/apple-auth';
function App() {
useEffect(() => {
// Initialize Apple ID (iOS and Web only)
initAppleAuth();
}, []);
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/auth" element={<Auth />} />
<Route path="/auth-loading" element={<AuthLoading />} />
</Routes>
</BrowserRouter>
);
}
export default App;
```
### 7. Edge Function (Handles Both JSON and form_post)
```typescript
// supabase/functions/auth-apple-callback/index.ts
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";
let applePublicKeys: jose.JWTVerifyGetKey | null = null;
let keysLastFetched = 0;
async function getApplePublicKeys(): Promise<jose.JWTVerifyGetKey> {
const now = Date.now();
if (applePublicKeys && (now - keysLastFetched) < 24 * 60 * 60 * 1000) 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;
}
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')!;
// CORS for JSON requests
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
if (req.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
try {
const contentType = req.headers.get('content-type') || '';
let idToken: string;
let userJson: string | null = null;
let platform = 'web';
let deeplinkScheme: string | undefined;
// Handle both JSON (from JS SDK) and form_post (from Android oauth://)
if (contentType.includes('application/json')) {
// iOS/Web: JSON from JS SDK
const body = await req.json();
idToken = body.id_token;
userJson = body.user;
platform = body.platform || 'web';
} else {
// Android: form_post from oauth://
const formData = await req.formData();
idToken = formData.get('id_token') as string;
userJson = formData.get('user') as string;
const state = formData.get('state') as string;
if (state?.includes('|')) {
const parts = state.split('|');
platform = parts[1];
if (parts[2]) deeplinkScheme = parts[2];
}
}
if (!idToken) {
if (contentType.includes('application/json')) {
return new Response(JSON.stringify({ error: 'No identity token' }), {
status: 400,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
}
return new Response(null, {
status: 302,
headers: { 'Location': `${appUrl}/auth?error=${encodeURIComponent('No identity token')}` }
});
}
// Verify token
const tokenPayload = await verifyAppleToken(idToken, clientId);
const appleUserId = tokenPayload.sub as string;
const email = tokenPayload.email as string | undefined;
// Parse user info
let displayName = 'Apple User', firstName = '', lastName = '';
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 {}
}
const userEmail = email || `${appleUserId}@apple.oauth`;
const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false }
});
const supabasePublic = createClient(supabaseUrl, anonKey);
// Create or find user
let userId: string;
const { data: createData, error: createError } = await supabaseAdmin.auth.admin.createUser({
email: userEmail,
email_confirm: true,
user_metadata: {
apple_user_id: appleUserId,
display_name: displayName,
first_name: firstName,
last_name: lastName,
provider: 'apple'
}
});
if (createError?.message?.includes('already been registered')) {
const { data: existingUsers } = await supabaseAdmin.auth.admin.listUsers();
const existingUser = existingUsers?.users.find(
u => u.email === userEmail || u.user_metadata?.apple_user_id === appleUserId
);
if (!existingUser) {
if (contentType.includes('application/json')) {
return new Response(JSON.stringify({ error: 'User not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
}
return new Response(null, {
status: 302,
headers: { 'Location': `${appUrl}/auth?error=${encodeURIComponent('User not found')}` }
});
}
userId = existingUser.id;
if (firstName || lastName) {
await supabaseAdmin.auth.admin.updateUserById(userId, {
user_metadata: { display_name: displayName, first_name: firstName, last_name: lastName }
});
}
} else if (createError) {
if (contentType.includes('application/json')) {
return new Response(JSON.stringify({ error: 'Failed to create user' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
}
return new Response(null, {
status: 302,
headers: { 'Location': `${appUrl}/auth?error=${encodeURIComponent('Failed to create user')}` }
});
} else {
userId = createData.user.id;
}
// Generate session
const { data: linkData, error: linkError } = await supabaseAdmin.auth.admin.generateLink({
type: 'magiclink',
email: userEmail,
});
if (linkError || !linkData) {
if (contentType.includes('application/json')) {
return new Response(JSON.stringify({ error: 'Failed to generate session' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
}
return new Response(null, {
status: 302,
headers: { 'Location': `${appUrl}/auth?error=${encodeURIComponent('Failed to generate session')}` }
});
}
const { data: sessionData, error: sessionError } = await supabasePublic.auth.verifyOtp({
token_hash: linkData.properties.hashed_token,
type: 'email',
});
if (sessionError || !sessionData.session) {
if (contentType.includes('application/json')) {
return new Response(JSON.stringify({ error: 'Failed to create session' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
}
return new Response(null, {
status: 302,
headers: { 'Location': `${appUrl}/auth?error=${encodeURIComponent('Failed to create session')}` }
});
}
const accessToken = sessionData.session.access_token;
const refreshToken = sessionData.session.refresh_token;
// Return tokens based on request type
if (contentType.includes('application/json')) {
// iOS/Web: Return JSON
return new Response(
JSON.stringify({ access_token: accessToken, refresh_token: refreshToken }),
{
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
}
);
} else {
// Android: Redirect to deeplink
const params = new URLSearchParams({ access_token: accessToken, refresh_token: refreshToken });
if (platform === 'android' && deeplinkScheme) {
return new Response(null, {
status: 302,
headers: { 'Location': `${deeplinkScheme}://oauth/auth?${params}` }
});
}
return new Response(null, {
status: 302,
headers: { 'Location': `${appUrl}/auth?${params}` }
});
}
} catch (error) {
console.error('Error:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
}
});
```
### 8. Environment Variables
**Frontend (.env file)**:
```env
# Your Service ID from Apple Developer Console (Step 2)
VITE_APPLE_CLIENT_ID=com.yourcompany.yourapp.web
# Your Supabase project URL
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
# Your app URL (must match domain registered in Apple Console)
VITE_APP_URL=https://your-app.lovable.app
```
**Backend (Supabase Edge Function Secrets)**:
Go to Supabase Dashboard → Project Settings → Edge Functions → Secrets
Add these secrets:
```
# Your Service ID (same as VITE_APPLE_CLIENT_ID)
APPLE_CLIENT_ID=com.yourcompany.yourapp.web
# Your Team ID from Apple Developer Console (top right corner)
APPLE_TEAM_ID=ABCD1234EF
# Your Key ID from the Sign In key you created
APPLE_KEY_ID=ABC123DEFG
# Contents of your .p8 file (replace newlines with \n)
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIGT...your key...\n-----END PRIVATE KEY-----
# Your app URL (must match VITE_APP_URL)
APP_URL=https://your-app.lovable.app
```
**Important for APPLE_PRIVATE_KEY**:
1. Open your `.p8` file in a text editor
2. Copy entire contents including `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`
3. Replace actual newlines with `\n` literal string
4. Example:
```
-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGBy...\n...rest of key...\n-----END PRIVATE KEY-----
```
## Platform Flows
**iOS Flow**:
1. Button click
2. AppleID.auth.signIn() (JS SDK)
3. Native Face ID dialog (instant!)
4. Credentials returned to JS
5. Navigate to /auth-loading
6. POST to edge function (JSON)
7. Get Supabase tokens
8. setSession()
9. Logged in
**Android Flow**:
1. Button click
2. window.location.href = 'oauth://...'
3. ASWebAuthenticationSession opens
4. User authenticates
5. Apple POSTs to edge function (form_post)
6. Edge function redirects to deeplink
7. WebView navigates to /auth?tokens
8. setSession()
9. Logged in
**Web Flow**:
1. Button click
2. AppleID.auth.signIn() (JS SDK)
3. Native browser dialog (instant!)
4. Credentials returned to JS
5. Navigate to /auth-loading
6. POST to edge function (JSON)
7. Get Supabase tokens
8. setSession()
9. Logged in
## Why This Works
- **iOS**: JS SDK triggers native dialog = instant, no redirects
- **Android**: oauth:// protocol needed (no native Apple support)
- **Web**: JS SDK works in browser = instant, no redirects
- **Edge function**: Handles both JSON (iOS/Web) and form_post (Android)
## Benefits
- Fast on iOS/Web (no redirect delays)
- Works on Android (oauth:// protocol)
- Clean code
- Better UX everywhere
## Quick Reference
### URLs Registered in Apple Developer Console
| URL Type | URL | Used For |
|----------|-----|----------|
| Domain | `your-app.lovable.app` | Your app domain |
| Domain | `your-project-ref.supabase.co` | Supabase functions domain |
| Return URL | `https://your-project-ref.supabase.co/functions/v1/auth-apple-callback` | Android form_post callback |
| Return URL | `https://your-app.lovable.app/auth/apple/callback` | JS SDK initialization |
### Service ID Configuration Summary
```
Service ID: com.yourcompany.yourapp.web
Primary App ID: com.yourcompany.yourapp
Domains:
- your-app.lovable.app
- your-project-ref.supabase.co
Return URLs:
- https://your-project-ref.supabase.co/functions/v1/auth-apple-callback
- https://your-app.lovable.app/auth/apple/callback
```
### Common Issues
**"Invalid redirect_uri" error**:
- Check both return URLs are registered exactly as shown
- No trailing slashes
- Must use https://
- Domain must match exactly
**"Invalid client_id" error**:
- Check VITE_APPLE_CLIENT_ID matches your Service ID
- Service ID format: `com.company.app.web`
**JS SDK not loading**:
- Check script tag in index.html
- Verify CDN URL: `https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js`
**Android not redirecting back**:
- Check deeplink scheme configured in app
- Verify edge function returns 302 redirect
- Check state parameter includes deeplink scheme