diff --git a/client/src/components/Auth/PasskeyAuth.tsx b/client/src/components/Auth/PasskeyAuth.tsx index fa127e22464..35cc8dd9ab0 100644 --- a/client/src/components/Auth/PasskeyAuth.tsx +++ b/client/src/components/Auth/PasskeyAuth.tsx @@ -3,7 +3,7 @@ import { useLocalize } from '~/hooks'; type PasskeyAuthProps = { mode: 'login' | 'register'; - onBack?: () => void; // Optional callback to return to normal login/register view + onBack?: () => void; }; const PasskeyAuth: React.FC = ({ mode, onBack }) => { @@ -11,25 +11,47 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); - // --- PASSKEY LOGIN FLOW --- - async function handlePasskeyLogin() { + const fetchWithError = async (url: string, options: RequestInit) => { + const response = await fetch(url, options); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Network request failed'); + } + return response.json(); + }; + + const base64URLToArrayBuffer = (base64url: string): ArrayBuffer => { + const padding = '='.repeat((4 - (base64url.length % 4)) % 4); + const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)).buffer; + }; + + const arrayBufferToBase64URL = (buffer: ArrayBuffer): string => { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + }; + + /** + * Passkey Login Flow + */ + const handlePasskeyLogin = async () => { if (!email) { - return alert(localize('Email is required for login.')); + alert('Email is required for login.'); + return; } + setLoading(true); try { - const challengeResponse = await fetch( + // 1. Fetch login challenge + const options = await fetchWithError( `/webauthn/login?email=${encodeURIComponent(email)}`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, + { method: 'GET' }, ); - if (!challengeResponse.ok) { - const errorData = await challengeResponse.json(); - throw new Error(errorData.error || 'Failed to get challenge'); - } - const options = await challengeResponse.json(); + + // 2. Convert challenge & credential IDs options.challenge = base64URLToArrayBuffer(options.challenge); if (options.allowCredentials) { options.allowCredentials = options.allowCredentials.map((cred: any) => ({ @@ -37,61 +59,76 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { id: base64URLToArrayBuffer(cred.id), })); } - const credential = await navigator.credentials.get({ publicKey: options }); + + // 3. Request credential + const credential = await navigator.credentials.create({ publicKey: options }); + if (!credential) { - throw new Error('Failed to obtain credential'); + new Error('Failed to obtain credential'); } - const authenticationResponse = { - id: credential.id, - rawId: arrayBufferToBase64URL(credential.rawId), - type: credential.type, + + // 4. Build credential object + const { id, rawId, response, type } = credential; + const authResponse = { + id, + rawId: arrayBufferToBase64URL(rawId), + type, response: { - authenticatorData: arrayBufferToBase64URL((credential.response as any).authenticatorData), - clientDataJSON: arrayBufferToBase64URL((credential.response as any).clientDataJSON), - signature: arrayBufferToBase64URL((credential.response as any).signature), - userHandle: (credential.response as any).userHandle - ? arrayBufferToBase64URL((credential.response as any).userHandle) + authenticatorData: arrayBufferToBase64URL( + (response as AuthenticatorAssertionResponse).authenticatorData + ), + clientDataJSON: arrayBufferToBase64URL( + (response as AuthenticatorAssertionResponse).clientDataJSON + ), + signature: arrayBufferToBase64URL( + (response as AuthenticatorAssertionResponse).signature + ), + userHandle: (response as AuthenticatorAssertionResponse).userHandle + ? arrayBufferToBase64URL( + (response as AuthenticatorAssertionResponse).userHandle! + ) : null, }, }; - const loginCallbackResponse = await fetch('/webauthn/login', { + + // 5. Send credential to server for verification + const result = await fetchWithError('/webauthn/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, credential: authenticationResponse }), + body: JSON.stringify({ email, credential: authResponse }), }); - const result = await loginCallbackResponse.json(); - if (result.user) { + + if (result?.user) { window.location.href = '/'; } else { - throw new Error(result.error || 'Authentication failed'); + new Error(result?.error || 'Authentication failed'); } } catch (error: any) { console.error('Passkey login error:', error); - alert(localize('Authentication failed: ') + error.message); + alert('Authentication failed: ' + error.message); } finally { setLoading(false); } - } + }; - // --- PASSKEY REGISTRATION FLOW --- - async function handlePasskeyRegister() { + /** + * Passkey Registration Flow + */ + const handlePasskeyRegister = async () => { if (!email) { - return alert(localize('Email is required for registration.')); + alert('Email is required for registration.'); + return; } + setLoading(true); try { - const challengeResponse = await fetch( + // 1. Fetch registration challenge + const options = await fetchWithError( `/webauthn/register?email=${encodeURIComponent(email)}`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, + { method: 'GET' }, ); - if (!challengeResponse.ok) { - const errorData = await challengeResponse.json(); - throw new Error(errorData.error || 'Failed to get challenge'); - } - const options = await challengeResponse.json(); + + // 2. Convert challenge & credential IDs options.challenge = base64URLToArrayBuffer(options.challenge); options.user.id = base64URLToArrayBuffer(options.user.id); if (options.excludeCredentials) { @@ -100,37 +137,49 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { id: base64URLToArrayBuffer(cred.id), })); } + + // 3. Request credential creation const credential = await navigator.credentials.create({ publicKey: options }); + if (!credential) { - throw new Error('Failed to create credential'); + new Error('Failed to create credential'); } + + // 4. Build registration object + const { id, rawId, response, type } = credential; const registrationResponse = { - id: credential.id, - rawId: arrayBufferToBase64URL(credential.rawId), - type: credential.type, + id, + rawId: arrayBufferToBase64URL(rawId), + type, response: { - clientDataJSON: arrayBufferToBase64URL((credential.response as any).clientDataJSON), - attestationObject: arrayBufferToBase64URL((credential.response as any).attestationObject), + clientDataJSON: arrayBufferToBase64URL( + (response as AuthenticatorAttestationResponse).clientDataJSON + ), + attestationObject: arrayBufferToBase64URL( + (response as AuthenticatorAttestationResponse).attestationObject + ), }, }; - const registerCallbackResponse = await fetch('/webauthn/register', { + + // 5. Send credential to server for verification + const result = await fetchWithError('/webauthn/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, credential: registrationResponse }), }); - const result = await registerCallbackResponse.json(); - if (result.user) { + + if (result?.user) { window.location.href = '/login'; } else { - throw new Error(result.error || 'Registration failed'); + new Error(result?.error || 'Registration failed'); } } catch (error: any) { console.error('Passkey registration error:', error); - alert(localize('Registration failed: ') + error.message); + alert('Registration failed: ' + error.message); } finally { setLoading(false); } - } + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -146,7 +195,7 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => {
= ({ mode, onBack }) => {
+
+ {onBack && (
@@ -203,19 +260,4 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { ); }; -export default PasskeyAuth; - -// Utility functions for base64url conversion -function base64URLToArrayBuffer(base64url: string): ArrayBuffer { - const padding = '='.repeat((4 - (base64url.length % 4)) % 4); - const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(base64); - return Uint8Array.from(binary, (c) => c.charCodeAt(0)).buffer; -} - -function arrayBufferToBase64URL(buffer: ArrayBuffer): string { - return btoa(String.fromCharCode(...new Uint8Array(buffer))) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} \ No newline at end of file +export default PasskeyAuth; \ No newline at end of file