diff --git a/.env.example b/.env.example index 2630d70a..21f48af6 100644 --- a/.env.example +++ b/.env.example @@ -30,4 +30,9 @@ VITE_ALLOW_CAPTCHA_BYPASS=false # For self-hosted Novu, replace with your instance URLs VITE_NOVU_APPLICATION_IDENTIFIER=your-novu-app-identifier VITE_NOVU_SOCKET_URL=wss://ws.novu.co -VITE_NOVU_API_URL=https://api.novu.co \ No newline at end of file +VITE_NOVU_API_URL=https://api.novu.co + +# Auth0 Configuration +# Get these from your Auth0 dashboard: https://manage.auth0.com +VITE_AUTH0_DOMAIN=your-tenant.auth0.com +VITE_AUTH0_CLIENT_ID=your-spa-client-id \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b367cee5..ec1c325a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "vite_react_shadcn_ts", "version": "0.0.0", "dependencies": { + "@auth0/auth0-react": "^2.8.0", + "@auth0/auth0-spa-js": "^2.8.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -63,6 +65,7 @@ "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", + "jose": "^6.1.0", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", "react": "^18.3.1", @@ -113,6 +116,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth0/auth0-react": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.8.0.tgz", + "integrity": "sha512-f3KOkq+TW7AC3T+ZAo9G0hNL339z15C9q00QDVrMGCzZAPyp8lvDHKcAs21d/u+GzhU5zmssvJTQggDR7JqxSA==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.7.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18 || ^19", + "react-dom": "^16.11.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.8.0.tgz", + "integrity": "sha512-Lu3dBius0CMRHNAWtw/RyIZH0b5B4jV9ZlVjpp5s7A11AO/XyABkNl0VW7Cz5ZHpAkXEba1CMnkxDG1/9LNIqg==", + "license": "MIT", + "dependencies": { + "browser-tabs-lock": "^1.2.15", + "dpop": "^2.1.1", + "es-cookie": "~1.3.2" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -6007,6 +6034,16 @@ "node": ">=8" } }, + "node_modules/browser-tabs-lock": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", + "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/browserslist": { "version": "4.27.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", @@ -6746,6 +6783,15 @@ "react": ">=16.12.0" } }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6917,6 +6963,12 @@ "node": ">=10.0.0" } }, + "node_modules/es-cookie": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", + "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -8486,6 +8538,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 59f924e8..18e7336f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "preview": "vite preview" }, "dependencies": { + "@auth0/auth0-react": "^2.8.0", + "@auth0/auth0-spa-js": "^2.8.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -66,6 +68,7 @@ "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", + "jose": "^6.1.0", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", "react": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index 74e0b669..e2060310 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { CacheMonitor } from "@/components/dev/CacheMonitor"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { AuthProvider } from "@/hooks/useAuth"; +import { Auth0Provider } from "@/contexts/Auth0Provider"; import { AuthModalProvider } from "@/contexts/AuthModalContext"; import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider"; import { Analytics } from "@vercel/analytics/react"; @@ -62,6 +63,7 @@ const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings") const Profile = lazy(() => import("./pages/Profile")); const UserSettings = lazy(() => import("./pages/UserSettings")); const AuthCallback = lazy(() => import("./pages/AuthCallback")); +const Auth0Callback = lazy(() => import("./pages/Auth0Callback")); // Utility routes (lazy-loaded) const NotFound = lazy(() => import("./pages/NotFound")); @@ -159,6 +161,7 @@ function AppContent(): React.JSX.Element { {/* User routes - lazy loaded */} } /> + } /> } /> } /> } /> @@ -190,11 +193,13 @@ function AppContent(): React.JSX.Element { const App = (): React.JSX.Element => ( - - - - - + + + + + + + {import.meta.env.DEV && ( <> diff --git a/src/components/auth/MigrationBanner.tsx b/src/components/auth/MigrationBanner.tsx new file mode 100644 index 00000000..883c5dd4 --- /dev/null +++ b/src/components/auth/MigrationBanner.tsx @@ -0,0 +1,105 @@ +/** + * Migration Banner Component + * + * Alerts existing Supabase users about Auth0 migration + */ + +import { useState, useEffect } from 'react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Info, X } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; +import { useProfile } from '@/hooks/useProfile'; +import { isAuth0Configured } from '@/lib/auth0Config'; + +const DISMISSED_KEY = 'auth0_migration_dismissed'; +const DISMISS_DURATION_DAYS = 7; + +export function MigrationBanner() { + const { user } = useAuth(); + const { data: profile } = useProfile(user?.id); + const [isDismissed, setIsDismissed] = useState(true); + + useEffect(() => { + // Check if banner should be shown + if (!user || !profile || !isAuth0Configured()) { + return; + } + + // Don't show if user already has Auth0 sub + if ((profile as any).auth0_sub) { + return; + } + + // Check if user dismissed the banner + const dismissedUntil = localStorage.getItem(DISMISSED_KEY); + if (dismissedUntil) { + const dismissedDate = new Date(dismissedUntil); + if (dismissedDate > new Date()) { + return; + } + } + + setIsDismissed(false); + }, [user, profile]); + + const handleDismiss = () => { + const dismissUntil = new Date(); + dismissUntil.setDate(dismissUntil.getDate() + DISMISS_DURATION_DAYS); + localStorage.setItem(DISMISSED_KEY, dismissUntil.toISOString()); + setIsDismissed(true); + }; + + const handleMigrate = () => { + // TODO: Implement migration flow + // For now, just direct to settings + window.location.href = '/settings?tab=security'; + }; + + if (isDismissed) { + return null; + } + + return ( + + +
+
+ + + Important: Account Security Upgrade + +

+ We're migrating to Auth0 for improved security and authentication. + Your account needs to be migrated to continue using all features. +

+
+
+ + +
+
+ +
+
+ ); +} diff --git a/src/components/settings/Auth0MFASettings.tsx b/src/components/settings/Auth0MFASettings.tsx new file mode 100644 index 00000000..377341be --- /dev/null +++ b/src/components/settings/Auth0MFASettings.tsx @@ -0,0 +1,176 @@ +/** + * Auth0 MFA Settings Component + * + * Display MFA status and provide enrollment/unenrollment options via Auth0 + */ + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Shield, Loader2, CheckCircle, AlertCircle } from 'lucide-react'; +import { useAuth0 } from '@auth0/auth0-react'; +import { getMFAStatus, triggerMFAEnrollment } from '@/lib/auth0Management'; +import { useToast } from '@/hooks/use-toast'; +import type { Auth0MFAStatus } from '@/types/auth0'; + +export function Auth0MFASettings() { + const { user, isAuthenticated } = useAuth0(); + const [mfaStatus, setMfaStatus] = useState(null); + const [loading, setLoading] = useState(true); + const { toast } = useToast(); + + useEffect(() => { + const fetchMFAStatus = async () => { + if (!isAuthenticated || !user?.sub) { + setLoading(false); + return; + } + + try { + const status = await getMFAStatus(user.sub); + setMfaStatus(status); + } catch (error) { + console.error('Error fetching MFA status:', error); + toast({ + variant: 'destructive', + title: 'Error', + description: 'Failed to load MFA status', + }); + } finally { + setLoading(false); + } + }; + + fetchMFAStatus(); + }, [isAuthenticated, user, toast]); + + const handleEnroll = async () => { + try { + await triggerMFAEnrollment('/settings?tab=security'); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Error', + description: 'Failed to start MFA enrollment', + }); + } + }; + + if (loading) { + return ( + + +
+ + Multi-Factor Authentication (MFA) +
+ + Add an extra layer of security to your account + +
+ +
+ +
+
+
+ ); + } + + return ( + + +
+ + Multi-Factor Authentication (MFA) +
+ + Add an extra layer of security to your account with two-factor authentication + +
+ + {/* MFA Status */} +
+
+ {mfaStatus?.enrolled ? ( + + ) : ( + + )} +
+

+ MFA Status +

+

+ {mfaStatus?.enrolled + ? 'Multi-factor authentication is active' + : 'MFA is not enabled on your account'} +

+
+
+ + {mfaStatus?.enrolled ? 'Enabled' : 'Disabled'} + +
+ + {/* Enrolled Methods */} + {mfaStatus?.enrolled && mfaStatus.methods.length > 0 && ( +
+

Active Methods:

+
+ {mfaStatus.methods.map((method) => ( +
+
+

{method.type}

+ {method.name && ( +

{method.name}

+ )} +
+ + {method.confirmed ? 'Active' : 'Pending'} + +
+ ))} +
+
+ )} + + {/* Info Alert */} + + + + {mfaStatus?.enrolled ? ( + <> + MFA is managed through Auth0. To add or remove authentication methods, + click the button below to manage your MFA settings. + + ) : ( + <> + Enable MFA to protect your account with an additional security layer. + You'll be redirected to Auth0 to set up your preferred authentication method. + + )} + + + + {/* Action Buttons */} +
+ {!mfaStatus?.enrolled ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/contexts/Auth0Provider.tsx b/src/contexts/Auth0Provider.tsx new file mode 100644 index 00000000..93f3c58f --- /dev/null +++ b/src/contexts/Auth0Provider.tsx @@ -0,0 +1,35 @@ +/** + * Auth0 Provider Wrapper + * + * Wraps the Auth0Provider from @auth0/auth0-react with our configuration + */ + +import { Auth0Provider as Auth0ProviderBase } from '@auth0/auth0-react'; +import { auth0Config, isAuth0Configured } from '@/lib/auth0Config'; +import { ReactNode } from 'react'; + +interface Auth0ProviderWrapperProps { + children: ReactNode; +} + +export function Auth0Provider({ children }: Auth0ProviderWrapperProps) { + // If Auth0 is not configured, render children without Auth0 provider + // This allows gradual migration from Supabase auth + if (!isAuth0Configured()) { + console.warn('[Auth0] Auth0 not configured, skipping Auth0 provider'); + return <>{children}; + } + + return ( + + {children} + + ); +} diff --git a/src/lib/auth0Config.ts b/src/lib/auth0Config.ts new file mode 100644 index 00000000..035efacf --- /dev/null +++ b/src/lib/auth0Config.ts @@ -0,0 +1,32 @@ +/** + * Auth0 Configuration + * + * Centralized configuration for Auth0 authentication + */ + +export const auth0Config = { + domain: import.meta.env.VITE_AUTH0_DOMAIN || '', + clientId: import.meta.env.VITE_AUTH0_CLIENT_ID || '', + authorizationParams: { + redirect_uri: typeof window !== 'undefined' ? `${window.location.origin}/auth/callback` : '', + audience: import.meta.env.VITE_AUTH0_DOMAIN ? `https://${import.meta.env.VITE_AUTH0_DOMAIN}/api/v2/` : '', + scope: 'openid profile email' + }, + cacheLocation: 'localstorage' as const, + useRefreshTokens: true, + useRefreshTokensFallback: true, +}; + +/** + * Check if Auth0 is properly configured + */ +export function isAuth0Configured(): boolean { + return !!(auth0Config.domain && auth0Config.clientId); +} + +/** + * Get Auth0 Management API audience + */ +export function getManagementAudience(): string { + return `https://${auth0Config.domain}/api/v2/`; +} diff --git a/src/lib/auth0Management.ts b/src/lib/auth0Management.ts new file mode 100644 index 00000000..e390adc5 --- /dev/null +++ b/src/lib/auth0Management.ts @@ -0,0 +1,138 @@ +/** + * Auth0 Management API Helper + * + * Provides helper functions to interact with Auth0 Management API + */ + +import { supabase } from '@/integrations/supabase/client'; +import type { Auth0MFAStatus, Auth0RoleInfo, ManagementTokenResponse } from '@/types/auth0'; + +/** + * Get Auth0 Management API access token via edge function + */ +export async function getManagementToken(): Promise { + const { data, error } = await supabase.functions.invoke( + 'auth0-get-management-token', + { method: 'POST' } + ); + + if (error || !data) { + throw new Error('Failed to get management token: ' + (error?.message || 'Unknown error')); + } + + return data.access_token; +} + +/** + * Get user's MFA enrollment status + */ +export async function getMFAStatus(userId: string): Promise { + try { + const token = await getManagementToken(); + const domain = import.meta.env.VITE_AUTH0_DOMAIN; + + const response = await fetch(`https://${domain}/api/v2/users/${userId}/enrollments`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch MFA status'); + } + + const enrollments = await response.json(); + + return { + enrolled: enrollments.length > 0, + methods: enrollments.map((e: any) => ({ + id: e.id, + type: e.type, + name: e.name, + confirmed: e.status === 'confirmed', + })), + }; + } catch (error) { + console.error('Error fetching MFA status:', error); + return { enrolled: false, methods: [] }; + } +} + +/** + * Get user's roles from Auth0 + */ +export async function getUserRoles(userId: string): Promise { + try { + const token = await getManagementToken(); + const domain = import.meta.env.VITE_AUTH0_DOMAIN; + + const response = await fetch(`https://${domain}/api/v2/users/${userId}/roles`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch user roles'); + } + + const roles = await response.json(); + + return roles.map((role: any) => ({ + id: role.id, + name: role.name, + description: role.description, + })); + } catch (error) { + console.error('Error fetching user roles:', error); + return []; + } +} + +/** + * Update user metadata + */ +export async function updateUserMetadata( + userId: string, + metadata: Record +): Promise { + try { + const token = await getManagementToken(); + const domain = import.meta.env.VITE_AUTH0_DOMAIN; + + const response = await fetch(`https://${domain}/api/v2/users/${userId}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user_metadata: metadata, + }), + }); + + return response.ok; + } catch (error) { + console.error('Error updating user metadata:', error); + return false; + } +} + +/** + * Trigger MFA enrollment for user + */ +export async function triggerMFAEnrollment(redirectUri?: string): Promise { + const domain = import.meta.env.VITE_AUTH0_DOMAIN; + const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID; + const redirect = redirectUri || `${window.location.origin}/settings`; + + // Redirect to Auth0 MFA enrollment page + window.location.href = `https://${domain}/authorize?` + + `client_id=${clientId}&` + + `response_type=code&` + + `redirect_uri=${encodeURIComponent(redirect)}&` + + `scope=openid profile email&` + + `prompt=enroll`; +} diff --git a/src/pages/Auth0Callback.tsx b/src/pages/Auth0Callback.tsx new file mode 100644 index 00000000..bb18a8ae --- /dev/null +++ b/src/pages/Auth0Callback.tsx @@ -0,0 +1,148 @@ +/** + * Auth0 Callback Page + * + * Handles Auth0 authentication callback and syncs user to Supabase + */ + +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth0 } from '@auth0/auth0-react'; +import { supabase } from '@/integrations/supabase/client'; +import { Header } from '@/components/layout/Header'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Loader2, CheckCircle, XCircle, Shield } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; + +type SyncStatus = 'processing' | 'success' | 'error'; + +export default function Auth0Callback() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { isAuthenticated, isLoading, user, getAccessTokenSilently } = useAuth0(); + const [syncStatus, setSyncStatus] = useState('processing'); + const [errorMessage, setErrorMessage] = useState(''); + const { toast } = useToast(); + + useEffect(() => { + const syncUserToSupabase = async () => { + if (isLoading) return; + + if (!isAuthenticated || !user) { + setSyncStatus('error'); + setErrorMessage('Authentication failed. Please try again.'); + return; + } + + try { + console.log('[Auth0Callback] Syncing user to Supabase:', user.sub); + + // Get Auth0 access token + const accessToken = await getAccessTokenSilently(); + + // Call sync edge function + const { data, error } = await supabase.functions.invoke('auth0-sync-user', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: { + email: user.email, + name: user.name, + picture: user.picture, + email_verified: user.email_verified, + }, + }); + + if (error || !data?.success) { + throw new Error(data?.error || error?.message || 'Sync failed'); + } + + console.log('[Auth0Callback] User synced successfully:', data.profile); + + setSyncStatus('success'); + + toast({ + title: 'Welcome back!', + description: 'You\'ve been signed in successfully.', + }); + + // Redirect after brief delay + setTimeout(() => { + const redirectTo = searchParams.get('redirect') || '/'; + navigate(redirectTo); + }, 1500); + } catch (error) { + console.error('[Auth0Callback] Sync error:', error); + setSyncStatus('error'); + setErrorMessage(error instanceof Error ? error.message : 'Failed to sync user data'); + } + }; + + syncUserToSupabase(); + }, [isAuthenticated, isLoading, user, getAccessTokenSilently, navigate, searchParams, toast]); + + return ( +
+
+ +
+
+ + +
+ {syncStatus === 'processing' && ( + + )} + {syncStatus === 'success' && ( + + )} + {syncStatus === 'error' && ( + + )} +
+ + {syncStatus === 'processing' && 'Completing Sign In...'} + {syncStatus === 'success' && 'Sign In Successful!'} + {syncStatus === 'error' && 'Sign In Error'} + + + {syncStatus === 'processing' && 'Please wait while we set up your account'} + {syncStatus === 'success' && 'Redirecting you to ThrillWiki...'} + {syncStatus === 'error' && 'Something went wrong during authentication'} + +
+ + {syncStatus === 'error' && ( + + + + {errorMessage || 'An unexpected error occurred. Please try signing in again.'} + + + +
+ +
+
+ )} + + {syncStatus === 'processing' && ( + +
+

Syncing your profile...

+

This should only take a moment

+
+
+ )} +
+
+
+
+ ); +} diff --git a/src/types/auth0.ts b/src/types/auth0.ts new file mode 100644 index 00000000..e727e8d9 --- /dev/null +++ b/src/types/auth0.ts @@ -0,0 +1,101 @@ +/** + * Auth0 Type Definitions + */ + +import type { User } from '@auth0/auth0-react'; + +/** + * Extended Auth0 user with app-specific metadata + */ +export interface Auth0User extends User { + app_metadata?: { + roles?: string[]; + supabase_id?: string; + migration_status?: 'pending' | 'completed' | 'failed'; + }; + user_metadata?: { + username?: string; + display_name?: string; + avatar_url?: string; + }; +} + +/** + * Auth0 MFA enrollment status + */ +export interface Auth0MFAStatus { + enrolled: boolean; + methods: Array<{ + id: string; + type: 'totp' | 'sms' | 'push' | 'email'; + name?: string; + confirmed: boolean; + }>; +} + +/** + * Auth0 role information + */ +export interface Auth0RoleInfo { + id: string; + name: string; + description?: string; +} + +/** + * Auth0 sync status for migration tracking + */ +export interface Auth0SyncStatus { + auth0_sub: string; + supabase_id?: string; + last_sync: string; + sync_status: 'success' | 'pending' | 'failed'; + error_message?: string; +} + +/** + * Auth0 authentication state + */ +export interface Auth0State { + isAuthenticated: boolean; + isLoading: boolean; + user: Auth0User | null; + accessToken: string | null; + idToken: string | null; + needsMigration: boolean; + isAuth0User: boolean; +} + +/** + * Auth0 Management API token response + */ +export interface ManagementTokenResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +/** + * Auth0 user sync request + */ +export interface UserSyncRequest { + auth0_sub: string; + email: string; + name?: string; + picture?: string; + email_verified: boolean; +} + +/** + * Auth0 user sync response + */ +export interface UserSyncResponse { + success: boolean; + profile?: { + id: string; + auth0_sub: string; + username: string; + email: string; + }; + error?: string; +} diff --git a/supabase/config.toml b/supabase/config.toml index dae3f8fc..3199783b 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -73,4 +73,16 @@ verify_jwt = true verify_jwt = false [functions.process-expired-bans] -verify_jwt = false \ No newline at end of file +verify_jwt = false + +[functions.auth0-sync-user] +verify_jwt = true + +[functions.auth0-get-roles] +verify_jwt = true + +[functions.auth0-webhook] +verify_jwt = false + +[functions.auth0-get-management-token] +verify_jwt = true \ No newline at end of file diff --git a/supabase/functions/_shared/auth0Jwt.ts b/supabase/functions/_shared/auth0Jwt.ts new file mode 100644 index 00000000..c6e9de1b --- /dev/null +++ b/supabase/functions/_shared/auth0Jwt.ts @@ -0,0 +1,71 @@ +/** + * Auth0 JWT Verification Utility + * + * Provides JWT verification using Auth0 JWKS + */ + +import { jwtVerify, createRemoteJWKSet, JWTPayload } from 'jose'; + +const AUTH0_DOMAIN = Deno.env.get('AUTH0_DOMAIN') || ''; +const AUTH0_CLIENT_ID = Deno.env.get('AUTH0_CLIENT_ID') || ''; + +/** + * Extended JWT payload with Auth0-specific claims + */ +export interface Auth0JWTPayload extends JWTPayload { + sub: string; + email?: string; + email_verified?: boolean; + name?: string; + picture?: string; + 'https://thrillwiki.com/roles'?: string[]; + amr?: string[]; // Authentication Methods Reference (includes 'mfa' if MFA verified) +} + +/** + * Verify Auth0 JWT token using JWKS + */ +export async function verifyAuth0Token(token: string): Promise { + try { + if (!AUTH0_DOMAIN || !AUTH0_CLIENT_ID) { + throw new Error('Auth0 configuration missing'); + } + + // Create JWKS fetcher + const JWKS = createRemoteJWKSet( + new URL(`https://${AUTH0_DOMAIN}/.well-known/jwks.json`) + ); + + // Verify token + const { payload } = await jwtVerify(token, JWKS, { + issuer: `https://${AUTH0_DOMAIN}/`, + audience: AUTH0_CLIENT_ID, + }); + + return payload as Auth0JWTPayload; + } catch (error) { + console.error('[Auth0JWT] Verification failed:', error); + throw new Error('Invalid Auth0 token'); + } +} + +/** + * Extract roles from Auth0 JWT payload + */ +export function extractRoles(payload: Auth0JWTPayload): string[] { + return payload['https://thrillwiki.com/roles'] || []; +} + +/** + * Check if user has verified MFA + */ +export function hasMFA(payload: Auth0JWTPayload): boolean { + return payload.amr?.includes('mfa') || false; +} + +/** + * Extract user ID (sub) from payload + */ +export function getUserId(payload: Auth0JWTPayload): string { + return payload.sub; +} diff --git a/supabase/functions/auth0-get-management-token/index.ts b/supabase/functions/auth0-get-management-token/index.ts new file mode 100644 index 00000000..1900436d --- /dev/null +++ b/supabase/functions/auth0-get-management-token/index.ts @@ -0,0 +1,114 @@ +/** + * Auth0 Get Management Token Edge Function + * + * Obtains Auth0 Management API access tokens for admin operations + */ + +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { verifyAuth0Token, extractRoles } from '../_shared/auth0Jwt.ts'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +// In-memory cache for management token +let cachedToken: string | null = null; +let tokenExpiry: number = 0; + +serve(async (req) => { + // Handle CORS preflight + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + // Verify user is authenticated + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + throw new Error('Missing authorization header'); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verifyAuth0Token(token); + + // Check if user has admin/moderator role + const roles = extractRoles(payload); + const isAuthorized = roles.some(role => + ['admin', 'moderator', 'superuser'].includes(role) + ); + + if (!isAuthorized) { + return new Response( + JSON.stringify({ error: 'Unauthorized - admin role required' }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 403, + } + ); + } + + // Check if cached token is still valid (5 min buffer) + const now = Date.now() / 1000; + if (cachedToken && tokenExpiry > now + 300) { + return new Response( + JSON.stringify({ + access_token: cachedToken, + token_type: 'Bearer', + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } + + // Get new management token + const AUTH0_DOMAIN = Deno.env.get('AUTH0_DOMAIN')!; + const M2M_CLIENT_ID = Deno.env.get('AUTH0_M2M_CLIENT_ID')!; + const M2M_CLIENT_SECRET = Deno.env.get('AUTH0_M2M_CLIENT_SECRET')!; + + const response = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: M2M_CLIENT_ID, + client_secret: M2M_CLIENT_SECRET, + audience: `https://${AUTH0_DOMAIN}/api/v2/`, + grant_type: 'client_credentials', + }), + }); + + if (!response.ok) { + throw new Error('Failed to get management token'); + } + + const data = await response.json(); + + // Cache the token + cachedToken = data.access_token; + tokenExpiry = now + data.expires_in; + + return new Response( + JSON.stringify({ + access_token: data.access_token, + token_type: data.token_type, + expires_in: data.expires_in, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error) { + console.error('[Auth0ManagementToken] Error:', error); + + return new Response( + JSON.stringify({ error: error.message }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + } + ); + } +}); diff --git a/supabase/functions/auth0-get-roles/index.ts b/supabase/functions/auth0-get-roles/index.ts new file mode 100644 index 00000000..9f5a8d2e --- /dev/null +++ b/supabase/functions/auth0-get-roles/index.ts @@ -0,0 +1,105 @@ +/** + * Auth0 Get Roles Edge Function + * + * Fetches user roles for authorization checks + */ + +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +import { verifyAuth0Token, getUserId, extractRoles } from '../_shared/auth0Jwt.ts'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + // Handle CORS preflight + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + // Get Auth0 token from Authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + throw new Error('Missing authorization header'); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verifyAuth0Token(token); + const auth0Sub = getUserId(payload); + + // Try to get roles from JWT first + const jwtRoles = extractRoles(payload); + + // Create Supabase client + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + // Get profile by auth0_sub + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('id') + .eq('auth0_sub', auth0Sub) + .single(); + + if (profileError || !profile) { + // Return JWT roles if profile not found + return new Response( + JSON.stringify({ + success: true, + roles: jwtRoles, + source: 'jwt', + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } + + // Fetch roles from database + const { data: dbRoles, error: rolesError } = await supabase + .from('user_roles') + .select('role') + .eq('user_id', profile.id); + + if (rolesError) { + throw rolesError; + } + + const roles = dbRoles?.map(r => r.role) || []; + + // Also fetch permissions + const { data: permissions } = await supabase + .rpc('get_user_management_permissions', { _user_id: profile.id }); + + return new Response( + JSON.stringify({ + success: true, + roles, + permissions, + source: 'database', + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error) { + console.error('[Auth0GetRoles] Error:', error); + + return new Response( + JSON.stringify({ + success: false, + error: error.message, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + } + ); + } +}); diff --git a/supabase/functions/auth0-sync-user/index.ts b/supabase/functions/auth0-sync-user/index.ts new file mode 100644 index 00000000..a8b793cf --- /dev/null +++ b/supabase/functions/auth0-sync-user/index.ts @@ -0,0 +1,158 @@ +/** + * Auth0 User Sync Edge Function + * + * Syncs Auth0 user data to Supabase profiles table after authentication + */ + +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +import { verifyAuth0Token, getUserId } from '../_shared/auth0Jwt.ts'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + // Handle CORS preflight + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + // Get Auth0 token from Authorization header + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + throw new Error('Missing authorization header'); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verifyAuth0Token(token); + const auth0Sub = getUserId(payload); + + // Parse request body + const { email, name, picture, email_verified } = await req.json(); + + // Create Supabase admin client + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + // Check if profile exists by auth0_sub + const { data: existingProfile } = await supabase + .from('profiles') + .select('id, username, email, auth0_sub') + .eq('auth0_sub', auth0Sub) + .single(); + + let profile; + + if (existingProfile) { + // Update existing profile + const { data, error } = await supabase + .from('profiles') + .update({ + email: email || existingProfile.email, + avatar_url: picture || existingProfile.avatar_url, + updated_at: new Date().toISOString(), + }) + .eq('auth0_sub', auth0Sub) + .select() + .single(); + + if (error) throw error; + profile = data; + } else { + // Check if profile exists by email (migration case) + const { data: emailProfile } = await supabase + .from('profiles') + .select('id, username, email') + .eq('email', email) + .is('auth0_sub', null) + .single(); + + if (emailProfile) { + // Link existing Supabase account to Auth0 + const { data, error } = await supabase + .from('profiles') + .update({ + auth0_sub: auth0Sub, + avatar_url: picture || emailProfile.avatar_url, + updated_at: new Date().toISOString(), + }) + .eq('id', emailProfile.id) + .select() + .single(); + + if (error) throw error; + profile = data; + } else { + // Create new profile + const username = email.split('@')[0] + '_' + Math.random().toString(36).substring(7); + + const { data, error } = await supabase + .from('profiles') + .insert({ + auth0_sub: auth0Sub, + email: email, + username: username, + display_name: name || username, + avatar_url: picture, + email_verified: email_verified || false, + }) + .select() + .single(); + + if (error) throw error; + profile = data; + } + } + + // Log sync to auth0_sync_log + await supabase + .from('auth0_sync_log') + .insert({ + auth0_sub: auth0Sub, + user_id: profile.id, + sync_status: 'success', + user_data: { email, name, picture }, + }); + + // Fetch user roles + const { data: roles } = await supabase + .from('user_roles') + .select('role') + .eq('user_id', profile.id); + + return new Response( + JSON.stringify({ + success: true, + profile: { + id: profile.id, + auth0_sub: profile.auth0_sub, + username: profile.username, + email: profile.email, + avatar_url: profile.avatar_url, + roles: roles?.map(r => r.role) || [], + }, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error) { + console.error('[Auth0Sync] Error:', error); + + return new Response( + JSON.stringify({ + success: false, + error: error.message, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + } + ); + } +}); diff --git a/supabase/functions/auth0-webhook/index.ts b/supabase/functions/auth0-webhook/index.ts new file mode 100644 index 00000000..7cd31872 --- /dev/null +++ b/supabase/functions/auth0-webhook/index.ts @@ -0,0 +1,154 @@ +/** + * Auth0 Webhook Edge Function + * + * Handles Auth0 webhook events for real-time sync + */ + +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +import { createHmac } from 'https://deno.land/std@0.168.0/node/crypto.ts'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-auth0-signature', +}; + +/** + * Verify Auth0 webhook signature + */ +function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean { + const hmac = createHmac('sha256', secret); + hmac.update(payload); + const expectedSignature = hmac.digest('hex'); + return signature === expectedSignature; +} + +serve(async (req) => { + // Handle CORS preflight + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + // Get webhook secret + const WEBHOOK_SECRET = Deno.env.get('AUTH0_WEBHOOK_SECRET'); + if (!WEBHOOK_SECRET) { + throw new Error('Webhook secret not configured'); + } + + // Verify signature + const signature = req.headers.get('x-auth0-signature'); + const body = await req.text(); + + if (signature && !verifyWebhookSignature(body, signature, WEBHOOK_SECRET)) { + return new Response( + JSON.stringify({ error: 'Invalid signature' }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 401, + } + ); + } + + const event = JSON.parse(body); + const { type, data } = event; + + // Create Supabase admin client + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + // Handle different event types + switch (type) { + case 'post-login': { + // Update last login timestamp + const auth0Sub = data.user?.user_id; + if (auth0Sub) { + await supabase + .from('profiles') + .update({ updated_at: new Date().toISOString() }) + .eq('auth0_sub', auth0Sub); + + // Log to audit log + await supabase.rpc('log_admin_action', { + p_user_id: null, + p_action: 'auth0_login', + p_details: { auth0_sub: auth0Sub, event_type: 'post-login' }, + }); + } + break; + } + + case 'post-change-password': { + // Log password change + const auth0Sub = data.user?.user_id; + if (auth0Sub) { + await supabase.rpc('log_admin_action', { + p_user_id: null, + p_action: 'password_changed', + p_details: { auth0_sub: auth0Sub, event_type: 'post-change-password' }, + }); + } + break; + } + + case 'post-user-registration': { + // Create initial profile if doesn't exist + const auth0Sub = data.user?.user_id; + const email = data.user?.email; + + if (auth0Sub && email) { + const { data: existing } = await supabase + .from('profiles') + .select('id') + .eq('auth0_sub', auth0Sub) + .single(); + + if (!existing) { + const username = email.split('@')[0] + '_' + Math.random().toString(36).substring(7); + + await supabase + .from('profiles') + .insert({ + auth0_sub: auth0Sub, + email: email, + username: username, + display_name: data.user?.name || username, + }); + } + } + break; + } + + default: + console.log('[Auth0Webhook] Unhandled event type:', type); + } + + // Log webhook event + await supabase + .from('auth0_sync_log') + .insert({ + auth0_sub: data.user?.user_id || 'unknown', + sync_status: 'success', + user_data: { event_type: type, data }, + }); + + return new Response( + JSON.stringify({ success: true }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error) { + console.error('[Auth0Webhook] Error:', error); + + return new Response( + JSON.stringify({ error: error.message }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + } + ); + } +});