Add passkey (WebAuthn) authentication with self-registration
Enable users to sign up and sign in using passkeys (biometrics/security keys) without needing a manually-created Authentik account. The existing SSO login remains as an alternative. Backend: - Add WebAuthn registration/authentication endpoints via py-webauthn - Issue HS256 JWTs for passkey users, with Redis-backed challenge storage - Dual JWT verification in auth middleware (issuer-based routing: passkey HS256 vs Authentik RS256) - PasskeyCredential model + migration making user.password nullable - UserRepository with full CRUD for users and credentials Frontend: - AuthUser type abstraction unifying OIDC and passkey users - Passkey service using @simplewebauthn/browser for WebAuthn ceremonies - LoginModal redesigned with Sign In / Sign Up tabs - Type migration from oidc-client-ts User to AuthUser across all services and components
This commit is contained in:
parent
95c0ddc4c6
commit
a8b7eace48
26 changed files with 1229 additions and 129 deletions
|
|
@ -1,44 +1,71 @@
|
|||
import { login, type AuthError } from '@/auth/authService';
|
||||
import { registerPasskey, loginWithPasskey } from '@/auth/passkeyService';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogDescription } from '@radix-ui/react-dialog';
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Home, LogIn, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Home, LogIn, AlertCircle, Loader2, Fingerprint, Mail } from 'lucide-react';
|
||||
|
||||
interface LoginModalProps {
|
||||
isOpen: boolean;
|
||||
onPasskeyLogin: (user: AuthUser) => void;
|
||||
}
|
||||
|
||||
const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => {
|
||||
const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onPasskeyLogin }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<AuthError | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleLogin = async () => {
|
||||
const handleSSOLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login();
|
||||
} catch (err) {
|
||||
setError(err as AuthError);
|
||||
setError((err as AuthError).message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
const handlePasskeyLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
handleLogin();
|
||||
try {
|
||||
const user = await loginWithPasskey();
|
||||
onPasskeyLogin(user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const handlePasskeyRegister = async () => {
|
||||
if (!email.trim()) {
|
||||
setError('Please enter your email address');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
try {
|
||||
const user = await registerPasskey(email.trim());
|
||||
onPasskeyLogin(user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => setError(null);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[425px]" showCloseButton={false}>
|
||||
<DialogHeader className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
|
|
@ -53,72 +80,107 @@ const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => {
|
|||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Beta Notice */}
|
||||
<div className="bg-muted/50 border rounded-lg p-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
We are currently in closed beta. Please contact Viktor to request an invitation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3">
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3 mb-4">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm text-destructive">{error.message}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetry}
|
||||
className="text-destructive border-destructive/30 hover:bg-destructive/10"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={clearError}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && !error && (
|
||||
<div className="flex items-center justify-center gap-3 py-4 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Redirecting to login...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Tabs defaultValue="signin">
|
||||
<TabsList>
|
||||
<TabsTrigger value="signin">Sign In</TabsTrigger>
|
||||
<TabsTrigger value="signup">Sign Up</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<DialogFooter>
|
||||
{!error && (
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TabsContent value="signin" className="space-y-4 pt-2">
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
) : (
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
)}
|
||||
Sign in with Passkey
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSSOLogin}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign in with SSO
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
)}
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="signup" className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handlePasskeyRegister();
|
||||
}}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-10 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePasskeyRegister}
|
||||
disabled={isLoading || !email.trim()}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
)}
|
||||
Create account with Passkey
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
A passkey uses your device's biometrics or security key for secure, passwordless authentication.
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue