Implement Auth0 migration

This commit is contained in:
gpt-engineer-app[bot]
2025-11-01 01:08:11 +00:00
parent 858320cd03
commit b2bf9a6e20
17 changed files with 1430 additions and 7 deletions

View File

@@ -30,4 +30,9 @@ VITE_ALLOW_CAPTCHA_BYPASS=false
# For self-hosted Novu, replace with your instance URLs # For self-hosted Novu, replace with your instance URLs
VITE_NOVU_APPLICATION_IDENTIFIER=your-novu-app-identifier VITE_NOVU_APPLICATION_IDENTIFIER=your-novu-app-identifier
VITE_NOVU_SOCKET_URL=wss://ws.novu.co VITE_NOVU_SOCKET_URL=wss://ws.novu.co
VITE_NOVU_API_URL=https://api.novu.co 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

61
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "vite_react_shadcn_ts", "name": "vite_react_shadcn_ts",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@auth0/auth0-react": "^2.8.0",
"@auth0/auth0-spa-js": "^2.8.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -63,6 +65,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jose": "^6.1.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -113,6 +116,30 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@babel/helper-string-parser": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -6007,6 +6034,16 @@
"node": ">=8" "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": { "node_modules/browserslist": {
"version": "4.27.0", "version": "4.27.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
@@ -6746,6 +6783,15 @@
"react": ">=16.12.0" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6917,6 +6963,12 @@
"node": ">=10.0.0" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -8486,6 +8538,15 @@
"jiti": "bin/jiti.js" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@@ -11,6 +11,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@auth0/auth0-react": "^2.8.0",
"@auth0/auth0-spa-js": "^2.8.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -66,6 +68,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jose": "^6.1.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",

View File

@@ -8,6 +8,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { CacheMonitor } from "@/components/dev/CacheMonitor"; import { CacheMonitor } from "@/components/dev/CacheMonitor";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth"; import { AuthProvider } from "@/hooks/useAuth";
import { Auth0Provider } from "@/contexts/Auth0Provider";
import { AuthModalProvider } from "@/contexts/AuthModalContext"; import { AuthModalProvider } from "@/contexts/AuthModalContext";
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider"; import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
@@ -62,6 +63,7 @@ const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings")
const Profile = lazy(() => import("./pages/Profile")); const Profile = lazy(() => import("./pages/Profile"));
const UserSettings = lazy(() => import("./pages/UserSettings")); const UserSettings = lazy(() => import("./pages/UserSettings"));
const AuthCallback = lazy(() => import("./pages/AuthCallback")); const AuthCallback = lazy(() => import("./pages/AuthCallback"));
const Auth0Callback = lazy(() => import("./pages/Auth0Callback"));
// Utility routes (lazy-loaded) // Utility routes (lazy-loaded)
const NotFound = lazy(() => import("./pages/NotFound")); const NotFound = lazy(() => import("./pages/NotFound"));
@@ -159,6 +161,7 @@ function AppContent(): React.JSX.Element {
{/* User routes - lazy loaded */} {/* User routes - lazy loaded */}
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/auth0-callback" element={<Auth0Callback />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
<Route path="/profile/:username" element={<Profile />} /> <Route path="/profile/:username" element={<Profile />} />
<Route path="/settings" element={<UserSettings />} /> <Route path="/settings" element={<UserSettings />} />
@@ -190,11 +193,13 @@ function AppContent(): React.JSX.Element {
const App = (): React.JSX.Element => ( const App = (): React.JSX.Element => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <Auth0Provider>
<AuthModalProvider> <AuthProvider>
<AppContent /> <AuthModalProvider>
</AuthModalProvider> <AppContent />
</AuthProvider> </AuthModalProvider>
</AuthProvider>
</Auth0Provider>
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<> <>
<ReactQueryDevtools initialIsOpen={false} position="bottom" /> <ReactQueryDevtools initialIsOpen={false} position="bottom" />

View File

@@ -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 (
<Alert className="mb-4 border-blue-500 bg-blue-50 dark:bg-blue-950">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<div className="flex items-start justify-between gap-4 flex-1">
<div className="flex-1">
<AlertDescription className="text-sm">
<strong className="text-blue-900 dark:text-blue-100">
Important: Account Security Upgrade
</strong>
<p className="mt-1 text-blue-800 dark:text-blue-200">
We're migrating to Auth0 for improved security and authentication.
Your account needs to be migrated to continue using all features.
</p>
</AlertDescription>
<div className="mt-3 flex gap-2">
<Button
size="sm"
onClick={handleMigrate}
className="bg-blue-600 hover:bg-blue-700"
>
Migrate Now
</Button>
<Button
size="sm"
variant="outline"
onClick={() => window.open('/docs/auth0-migration', '_blank')}
>
Learn More
</Button>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
className="shrink-0 h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</Alert>
);
}

View File

@@ -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<Auth0MFAStatus | null>(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 (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle>Multi-Factor Authentication (MFA)</CardTitle>
</div>
<CardDescription>
Add an extra layer of security to your account
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle>Multi-Factor Authentication (MFA)</CardTitle>
</div>
<CardDescription>
Add an extra layer of security to your account with two-factor authentication
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* MFA Status */}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
{mfaStatus?.enrolled ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<AlertCircle className="h-5 w-5 text-amber-500" />
)}
<div>
<p className="font-medium">
MFA Status
</p>
<p className="text-sm text-muted-foreground">
{mfaStatus?.enrolled
? 'Multi-factor authentication is active'
: 'MFA is not enabled on your account'}
</p>
</div>
</div>
<Badge variant={mfaStatus?.enrolled ? 'default' : 'secondary'}>
{mfaStatus?.enrolled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
{/* Enrolled Methods */}
{mfaStatus?.enrolled && mfaStatus.methods.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Active Methods:</p>
<div className="space-y-2">
{mfaStatus.methods.map((method) => (
<div
key={method.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="text-sm font-medium capitalize">{method.type}</p>
{method.name && (
<p className="text-xs text-muted-foreground">{method.name}</p>
)}
</div>
<Badge variant={method.confirmed ? 'default' : 'secondary'}>
{method.confirmed ? 'Active' : 'Pending'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Info Alert */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{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.
</>
)}
</AlertDescription>
</Alert>
{/* Action Buttons */}
<div className="flex gap-2">
{!mfaStatus?.enrolled ? (
<Button onClick={handleEnroll} className="w-full">
Enable MFA
</Button>
) : (
<Button onClick={handleEnroll} variant="outline" className="w-full">
Manage MFA Settings
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Auth0ProviderBase
domain={auth0Config.domain}
clientId={auth0Config.clientId}
authorizationParams={auth0Config.authorizationParams}
cacheLocation={auth0Config.cacheLocation}
useRefreshTokens={auth0Config.useRefreshTokens}
useRefreshTokensFallback={auth0Config.useRefreshTokensFallback}
>
{children}
</Auth0ProviderBase>
);
}

32
src/lib/auth0Config.ts Normal file
View File

@@ -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/`;
}

138
src/lib/auth0Management.ts Normal file
View File

@@ -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<string> {
const { data, error } = await supabase.functions.invoke<ManagementTokenResponse>(
'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<Auth0MFAStatus> {
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<Auth0RoleInfo[]> {
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<string, any>
): Promise<boolean> {
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<void> {
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`;
}

148
src/pages/Auth0Callback.tsx Normal file
View File

@@ -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<SyncStatus>('processing');
const [errorMessage, setErrorMessage] = useState<string>('');
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 (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-16">
<div className="max-w-md mx-auto">
<Card>
<CardHeader>
<div className="flex items-center justify-center mb-4">
{syncStatus === 'processing' && (
<Loader2 className="h-12 w-12 text-primary animate-spin" />
)}
{syncStatus === 'success' && (
<CheckCircle className="h-12 w-12 text-green-500" />
)}
{syncStatus === 'error' && (
<XCircle className="h-12 w-12 text-destructive" />
)}
</div>
<CardTitle className="text-center">
{syncStatus === 'processing' && 'Completing Sign In...'}
{syncStatus === 'success' && 'Sign In Successful!'}
{syncStatus === 'error' && 'Sign In Error'}
</CardTitle>
<CardDescription className="text-center">
{syncStatus === 'processing' && 'Please wait while we set up your account'}
{syncStatus === 'success' && 'Redirecting you to ThrillWiki...'}
{syncStatus === 'error' && 'Something went wrong during authentication'}
</CardDescription>
</CardHeader>
{syncStatus === 'error' && (
<CardContent>
<Alert variant="destructive">
<AlertDescription>
{errorMessage || 'An unexpected error occurred. Please try signing in again.'}
</AlertDescription>
</Alert>
<div className="mt-4 space-y-2">
<Button
onClick={() => navigate('/auth')}
className="w-full"
>
Return to Sign In
</Button>
</div>
</CardContent>
)}
{syncStatus === 'processing' && (
<CardContent>
<div className="space-y-2 text-sm text-muted-foreground text-center">
<p>Syncing your profile...</p>
<p>This should only take a moment</p>
</div>
</CardContent>
)}
</Card>
</div>
</main>
</div>
);
}

101
src/types/auth0.ts Normal file
View File

@@ -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;
}

View File

@@ -73,4 +73,16 @@ verify_jwt = true
verify_jwt = false verify_jwt = false
[functions.process-expired-bans] [functions.process-expired-bans]
verify_jwt = false 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

View File

@@ -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<Auth0JWTPayload> {
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;
}

View File

@@ -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,
}
);
}
});

View File

@@ -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,
}
);
}
});

View File

@@ -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,
}
);
}
});

View File

@@ -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,
}
);
}
});