mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -05:00
Implement Auth0 migration
This commit is contained in:
@@ -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
|
||||
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
61
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
src/App.tsx
15
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 */}
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/auth/auth0-callback" element={<Auth0Callback />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/profile/:username" element={<Profile />} />
|
||||
<Route path="/settings" element={<UserSettings />} />
|
||||
@@ -190,11 +193,13 @@ function AppContent(): React.JSX.Element {
|
||||
|
||||
const App = (): React.JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<AuthModalProvider>
|
||||
<AppContent />
|
||||
</AuthModalProvider>
|
||||
</AuthProvider>
|
||||
<Auth0Provider>
|
||||
<AuthProvider>
|
||||
<AuthModalProvider>
|
||||
<AppContent />
|
||||
</AuthModalProvider>
|
||||
</AuthProvider>
|
||||
</Auth0Provider>
|
||||
{import.meta.env.DEV && (
|
||||
<>
|
||||
<ReactQueryDevtools initialIsOpen={false} position="bottom" />
|
||||
|
||||
105
src/components/auth/MigrationBanner.tsx
Normal file
105
src/components/auth/MigrationBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
src/components/settings/Auth0MFASettings.tsx
Normal file
176
src/components/settings/Auth0MFASettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/contexts/Auth0Provider.tsx
Normal file
35
src/contexts/Auth0Provider.tsx
Normal 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
32
src/lib/auth0Config.ts
Normal 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
138
src/lib/auth0Management.ts
Normal 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
148
src/pages/Auth0Callback.tsx
Normal 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
101
src/types/auth0.ts
Normal 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;
|
||||
}
|
||||
@@ -73,4 +73,16 @@ verify_jwt = true
|
||||
verify_jwt = false
|
||||
|
||||
[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
|
||||
71
supabase/functions/_shared/auth0Jwt.ts
Normal file
71
supabase/functions/_shared/auth0Jwt.ts
Normal 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;
|
||||
}
|
||||
114
supabase/functions/auth0-get-management-token/index.ts
Normal file
114
supabase/functions/auth0-get-management-token/index.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
105
supabase/functions/auth0-get-roles/index.ts
Normal file
105
supabase/functions/auth0-get-roles/index.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
158
supabase/functions/auth0-sync-user/index.ts
Normal file
158
supabase/functions/auth0-sync-user/index.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
154
supabase/functions/auth0-webhook/index.ts
Normal file
154
supabase/functions/auth0-webhook/index.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user