diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 00000000..d0031ed2 --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -0,0 +1,257 @@ +# ThrillWiki Authentication System + +## Overview + +ThrillWiki implements a comprehensive authentication system following Supabase best practices with multiple sign-in methods, MFA support, and advanced security features. + +## Supported Authentication Methods + +### 1. Email & Password +- Standard email/password authentication +- Password strength validation (minimum 6 characters) +- Email confirmation required by default +- Password reset flow with secure email links + +### 2. Magic Link (Passwordless) +- One-click email authentication +- No password required +- Expires after 1 hour +- Rate limited to prevent abuse (1 request per 60 seconds) + +### 3. Email OTP (One-Time Password) +- 6-digit verification code sent via email +- Alternative to Magic Links +- Expires after 1 hour +- Ideal for mobile apps + +### 4. OAuth Social Login +- **Google**: Full profile and email access +- **Discord**: Identity and email access +- Automatic identity linking with matching emails +- Manual identity linking for logged-in users + +## Security Features + +### Multi-Factor Authentication (MFA/TOTP) +- App-based authenticator (Google Authenticator, Authy, etc.) +- Required for moderators and administrators +- QR code enrollment with manual secret fallback +- AAL2 (Authenticator Assurance Level 2) enforcement +- Automatic session step-up challenges + +### Session Management +- Multiple active sessions across devices +- Session expiry monitoring with proactive refresh +- Device and browser detection +- IP address tracking (last 8 chars of hash for privacy) +- Revoke individual sessions or all others + +### Sign Out Options +- **Global**: Sign out from all devices (default) +- **Local**: Sign out from current device only +- **Others**: Sign out from all other devices + +### Identity Management +- Link multiple OAuth providers to one account +- Disconnect OAuth identities (with safety checks) +- Add password backup to OAuth-only accounts +- Prevents account orphaning (requires at least 1 auth method) +- AAL2 required for disconnecting identities + +## Implementation Details + +### Critical Security Fixes +1. **Email Redirect URLs**: All signup flows include `emailRedirectTo` parameter +2. **MFA Bypass Prevention**: Canceling MFA prompt triggers forced sign-out +3. **Non-Dismissable MFA Modal**: Cannot close MFA challenge without completing +4. **Session Monitoring**: Tokens refreshed 5 minutes before expiry + +### Automatic Identity Linking +When a user signs in with OAuth using an email that already exists: +- Supabase automatically links the new identity +- Removes unconfirmed identities to prevent takeover attacks +- Logs linking event to audit trail +- Only works with confirmed email addresses + +### Manual Identity Linking +Users can manually link additional OAuth providers: +```typescript +import { linkOAuthIdentity } from '@/lib/identityService'; + +// Link a new provider +const result = await linkOAuthIdentity('google'); +``` + +### Password Reset Flow +For OAuth-only accounts that want to add password: +```typescript +import { addPasswordToAccount } from '@/lib/identityService'; + +// Triggers password setup email +const result = await addPasswordToAccount(); +// User receives email with link to set password +``` + +## Email OTP Implementation + +### For Developers +1. Modify Supabase email template to include `{{ .Token }}` +2. Use the `EmailOTPInput` component: + +```tsx +import { EmailOTPInput } from '@/components/auth/EmailOTPInput'; + + { + const { error } = await supabase.auth.verifyOtp({ + email: userEmail, + token: code, + type: 'email', + }); + }} + onCancel={() => setShowOTP(false)} + onResend={async () => { + await supabase.auth.signInWithOtp({ email: userEmail }); + }} +/> +``` + +## User Flows + +### Sign Up with Email +1. User enters email, password, username, display name +2. System validates email (no disposable addresses) +3. CAPTCHA verification (if enabled) +4. Confirmation email sent to user +5. User clicks link → Redirected to `/auth/callback` +6. Account confirmed and logged in + +### Sign In with MFA Enrolled +1. User enters email and password +2. System authenticates credentials +3. If MFA enrolled → Shows MFA challenge +4. User enters 6-digit TOTP code +5. Session upgraded to AAL2 +6. User granted full access + +### Linking Additional Provider +1. User navigates to Settings → Security +2. Clicks "Connect" on desired OAuth provider +3. Completes OAuth flow with provider +4. Returns to ThrillWiki with linked identity +5. Can now sign in with either method + +### Disconnecting Provider +1. User navigates to Settings → Security +2. Clicks "Disconnect" on OAuth provider +3. System verifies: + - Not the last identity + - Has password backup OR another OAuth provider + - User has AAL2 session (MFA verified if enrolled) +4. Identity disconnected and logged to audit + +## Error Handling + +### Common Error Codes +- `email_exists`: Email already registered +- `invalid_credentials`: Wrong email/password +- `email_not_confirmed`: Email not verified yet +- `mfa_verification_failed`: Wrong TOTP code +- `identity_already_exists`: Provider already linked +- `single_identity_not_deletable`: Cannot remove last auth method +- `insufficient_aal`: MFA verification required + +### User-Friendly Messages +All errors are converted to user-friendly messages: +```typescript +import { handleError } from '@/lib/errorHandler'; + +try { + // Auth operation +} catch (error) { + handleError(error, { action: 'Sign In' }); + // Shows toast with friendly message +} +``` + +## Configuration + +### Email Templates +Configure in Supabase Dashboard → Authentication → Email Templates + +**Magic Link Template:** +```html +

Magic Link

+

Follow this link to login:

+

Log In

+``` + +**OTP Template:** +```html +

Verification Code

+

Your verification code is:

+

{{ .Token }}

+

This code expires in 1 hour.

+``` + +### Redirect URLs +Configure in Supabase Dashboard → Authentication → URL Configuration: +- **Site URL**: `https://www.thrillwiki.com` +- **Redirect URLs**: + - `https://www.thrillwiki.com/auth/callback` + - `http://localhost:8080/auth/callback` (development) + +## Best Practices + +### For Users +- Enable MFA for additional security +- Link multiple providers for backup access +- Use strong, unique passwords (if using email/password) +- Review active sessions regularly +- Sign out from unfamiliar devices + +### For Developers +- Always include `emailRedirectTo` in signup calls +- Validate emails to prevent disposable addresses +- Never log sensitive data (passwords, tokens) +- Use AAL2 checks for critical operations +- Implement proper error handling with user-friendly messages +- Test all authentication flows thoroughly + +## Audit Logging + +All authentication events are logged: +- Sign in/sign out +- MFA enrollment/verification +- Identity linking/unlinking +- Password changes +- Session revocations + +Access logs via `admin_audit_log` table or Settings → Security → Sessions. + +## Testing Checklist + +- [ ] Sign up with email/password +- [ ] Confirm email and log in +- [ ] Reset password +- [ ] Magic link sign in +- [ ] OAuth sign in (Google) +- [ ] OAuth sign in (Discord) +- [ ] Enroll MFA +- [ ] Sign in with MFA +- [ ] Cancel MFA (should sign out) +- [ ] Link additional OAuth provider +- [ ] Disconnect OAuth provider +- [ ] Add password to OAuth-only account +- [ ] Revoke session +- [ ] Sign out (all scopes) + +## Support + +For issues or questions about authentication: +- Check Supabase auth logs in dashboard +- Review browser console for client errors +- Check network requests in DevTools +- Verify email template configuration +- Confirm redirect URLs are correct diff --git a/src/components/auth/AuthModal.tsx b/src/components/auth/AuthModal.tsx index f47af9f1..b86af810 100644 --- a/src/components/auth/AuthModal.tsx +++ b/src/components/auth/AuthModal.tsx @@ -244,6 +244,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod email: formData.email, password: formData.password, options: { + emailRedirectTo: `${window.location.origin}/auth/callback`, data: { username: formData.username, display_name: formData.displayName diff --git a/src/components/auth/EmailOTPInput.tsx b/src/components/auth/EmailOTPInput.tsx new file mode 100644 index 00000000..87474ead --- /dev/null +++ b/src/components/auth/EmailOTPInput.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Mail } from 'lucide-react'; + +interface EmailOTPInputProps { + email: string; + onVerify: (code: string) => Promise; + onCancel: () => void; + onResend: () => Promise; + loading?: boolean; +} + +export function EmailOTPInput({ + email, + onVerify, + onCancel, + onResend, + loading = false +}: EmailOTPInputProps) { + const [code, setCode] = useState(''); + const [resending, setResending] = useState(false); + + const handleVerify = async () => { + if (code.length === 6) { + await onVerify(code); + } + }; + + const handleResend = async () => { + setResending(true); + try { + await onResend(); + setCode(''); // Reset code input + } finally { + setResending(false); + } + }; + + return ( +
+ + + + We've sent a 6-digit verification code to {email} + + + +
+
+ Enter the 6-digit code +
+ + + + + + + + + + + + +
+ + +
+ + +
+
+ ); +} diff --git a/src/components/settings/IdentityManagement.tsx b/src/components/settings/IdentityManagement.tsx new file mode 100644 index 00000000..47a4b342 --- /dev/null +++ b/src/components/settings/IdentityManagement.tsx @@ -0,0 +1,225 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Link, Unlink, Shield, AlertCircle } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { + getUserIdentities, + disconnectIdentity, + linkOAuthIdentity, + checkDisconnectSafety, + addPasswordToAccount +} from '@/lib/identityService'; +import type { UserIdentity, OAuthProvider } from '@/types/identity'; + +export function IdentityManagement() { + const { toast } = useToast(); + const [identities, setIdentities] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + + useEffect(() => { + loadIdentities(); + }, []); + + const loadIdentities = async () => { + setLoading(true); + const data = await getUserIdentities(); + setIdentities(data); + setLoading(false); + }; + + const handleDisconnect = async (provider: OAuthProvider) => { + // Safety check + const safety = await checkDisconnectSafety(provider); + if (!safety.canDisconnect) { + toast({ + variant: 'destructive', + title: 'Cannot Disconnect', + description: safety.reason === 'last_identity' + ? 'This is your only sign-in method. Add a password or another provider first.' + : 'Please add a password before disconnecting your last social login.', + }); + return; + } + + setActionLoading(provider); + const result = await disconnectIdentity(provider); + + if (result.success) { + toast({ + title: 'Provider Disconnected', + description: `${provider} has been removed from your account.`, + }); + await loadIdentities(); + } else if (result.requiresAAL2) { + toast({ + variant: 'destructive', + title: 'MFA Required', + description: result.error || 'Please verify your identity with MFA.', + }); + } else { + toast({ + variant: 'destructive', + title: 'Failed to Disconnect', + description: result.error, + }); + } + + setActionLoading(null); + }; + + const handleLink = async (provider: OAuthProvider) => { + setActionLoading(provider); + const result = await linkOAuthIdentity(provider); + + if (result.success) { + // OAuth redirect will happen automatically + toast({ + title: 'Redirecting...', + description: `Opening ${provider} sign-in window...`, + }); + } else { + toast({ + variant: 'destructive', + title: 'Failed to Link', + description: result.error, + }); + setActionLoading(null); + } + }; + + const handleAddPassword = async () => { + setActionLoading('password'); + const result = await addPasswordToAccount(); + + if (result.success) { + toast({ + title: 'Check Your Email', + description: `We've sent a password setup link to ${result.email}`, + }); + } else { + toast({ + variant: 'destructive', + title: 'Failed to Add Password', + description: result.error, + }); + } + + setActionLoading(null); + }; + + const hasProvider = (provider: string) => + identities.some(i => i.provider === provider); + + const hasPassword = hasProvider('email'); + + const providers: { id: OAuthProvider; label: string; icon: string }[] = [ + { id: 'google', label: 'Google', icon: 'G' }, + { id: 'discord', label: 'Discord', icon: 'D' }, + ]; + + if (loading) { + return ( + + + Connected Accounts + Loading... + + + ); + } + + return ( + + + + + Connected Accounts + + + Link multiple sign-in methods to your account for easy access + + + + {identities.length === 1 && !hasPassword && ( + + + + Add a password as a backup sign-in method + + + )} + + {/* Password Authentication */} +
+
+
+ +
+
+
Email & Password
+
+ {hasPassword ? 'Connected' : 'Not set up'} +
+
+
+ {!hasPassword && ( + + )} +
+ + {/* OAuth Providers */} + {providers.map((provider) => { + const isConnected = hasProvider(provider.id); + + return ( +
+
+
+ {provider.icon} +
+
+
{provider.label}
+
+ {isConnected ? 'Connected' : 'Not connected'} +
+
+
+ +
+ ); + })} +
+
+ ); +} diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index 51711c84..4bc274fb 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -261,77 +261,77 @@ export function SecurityTab() { - {/* Connected Accounts */} - - -
- - Connected Accounts -
- - Manage your social login connections for easier access to your account. - -
- - {loadingIdentities ? ( -
- -
- ) : ( - connectedAccounts.map(account => { - const isConnected = !!account.identity; - const isDisconnecting = disconnectingProvider === account.provider; - const email = account.identity?.identity_data?.email; + {/* Identity Management Section */} + + +
+ + Connected Accounts +
+ + Manage your social login connections for easier access to your account. + +
+ + {loadingIdentities ? ( +
+ +
+ ) : ( + connectedAccounts.map(account => { + const isConnected = !!account.identity; + const isDisconnecting = disconnectingProvider === account.provider; + const email = account.identity?.identity_data?.email; - return ( -
-
-
- {account.icon} -
-
-

{account.provider}

- {isConnected && email && ( -

{email}

- )} -
+ return ( +
+
+
+ {account.icon}
-
- {isConnected ? ( - <> - Connected - - - ) : ( - +
+

{account.provider}

+ {isConnected && email && ( +

{email}

)}
- ); - }) - )} - - +
+ {isConnected ? ( + <> + Connected + + + ) : ( + + )} +
+
+ ); + }) + )} + +
{/* Two-Factor Authentication - Full Width */} diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 49a113cc..c722ff09 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -15,7 +15,7 @@ interface AuthContextType { loading: boolean; pendingEmail: string | null; sessionError: string | null; - signOut: () => Promise; + signOut: (scope?: 'global' | 'local' | 'others') => Promise; verifySession: () => Promise; clearPendingEmail: () => void; checkAalStepUp: () => Promise; @@ -123,6 +123,24 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { await supabase.auth.signOut(); return; } + + // Enhanced session monitoring: Proactively refresh tokens before expiry + const expiresAt = session.expires_at; + if (expiresAt) { + const now = Math.floor(Date.now() / 1000); + const timeUntilExpiry = expiresAt - now; + + // Refresh 5 minutes (300 seconds) before expiry + if (timeUntilExpiry < 300 && timeUntilExpiry > 0) { + authLog('[Auth] Token expiring soon, refreshing session...'); + const { error } = await supabase.auth.refreshSession(); + if (error) { + authError('[Auth] Session refresh failed:', error); + } else { + authLog('[Auth] Session refreshed successfully'); + } + } + } } else { setAal(null); } @@ -218,12 +236,23 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { }; }, []); - const signOut = async () => { - authLog('[Auth] Signing out...'); - const result = await signOutUser(); - if (!result.success) { - authError('Error signing out:', result.error); - throw new Error(result.error); + const signOut = async (scope: 'global' | 'local' | 'others' = 'global') => { + authLog('[Auth] Signing out with scope:', scope); + + try { + const { error } = await supabase.auth.signOut({ scope }); + + if (error) throw error; + + // Clear all auth flags (only on global/local sign out) + if (scope !== 'others') { + clearAllAuthFlags(); + } + + authLog('[Auth] Sign out successful'); + } catch (error) { + authError('[Auth] Error signing out:', error); + throw error; } }; diff --git a/src/lib/identityService.ts b/src/lib/identityService.ts index 3d185bdb..6fdaa6e8 100644 --- a/src/lib/identityService.ts +++ b/src/lib/identityService.ts @@ -223,6 +223,76 @@ export async function connectIdentity( } } +/** + * Link an OAuth identity to the logged-in user's account (Manual Linking) + * Requires user to be authenticated + */ +export async function linkOAuthIdentity( + provider: OAuthProvider +): Promise { + try { + const { data, error } = await supabase.auth.linkIdentity({ + provider + }); + + if (error) throw error; + + // Log audit event + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + await logIdentityChange(user.id, 'identity_linked', { + provider, + method: 'manual', + timestamp: new Date().toISOString() + }); + } + + return { success: true }; + } catch (error) { + const errorMsg = getErrorMessage(error); + logger.error('Failed to link identity', { + action: 'identity_link', + provider, + error: errorMsg + }); + return { + success: false, + error: errorMsg + }; + } +} + +/** + * Log when automatic identity linking occurs + * Called internally when Supabase automatically links identities + */ +export async function logAutomaticIdentityLinking( + userId: string, + provider: OAuthProvider, + email: string +): Promise { + try { + await logIdentityChange(userId, 'identity_auto_linked', { + provider, + email, + method: 'automatic', + timestamp: new Date().toISOString() + }); + + logger.info('Automatic identity linking logged', { + userId, + provider, + action: 'identity_auto_linked' + }); + } catch (error) { + logger.error('Failed to log automatic identity linking', { + userId, + provider, + error: getErrorMessage(error) + }); + } +} + /** * Add password authentication to an OAuth-only account diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index f65f2776..289a4e95 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -316,6 +316,7 @@ export default function Auth() { email: formData.email, password: formData.password, options: { + emailRedirectTo: `${window.location.origin}/auth/callback`, captchaToken: tokenToUse, data: { username: formData.username,