Skip to content

Commit

Permalink
refactor: PasskeyAuth.tsx
Browse files Browse the repository at this point in the history
  • Loading branch information
rubentalstra committed Feb 12, 2025
1 parent 1ab5bc4 commit 1e1b865
Showing 1 changed file with 123 additions and 81 deletions.
204 changes: 123 additions & 81 deletions client/src/components/Auth/PasskeyAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,95 +3,132 @@ 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<PasskeyAuthProps> = ({ mode, onBack }) => {
const localize = useLocalize();
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) => ({
...cred,
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) {
Expand All @@ -100,37 +137,49 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ 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();
Expand All @@ -146,7 +195,7 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
<form onSubmit={handleSubmit}>
<div className="relative mb-4">
<input
type="text"
type="email"
id="passkey-email"
autoComplete="email"
aria-label={localize('com_auth_email')}
Expand All @@ -162,29 +211,37 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
<label
htmlFor="passkey-email"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform
bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2
peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4
peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600
"
>
{localize('com_auth_email_address')}
</label>
</div>

<button
type="submit"
disabled={loading}
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50"
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm
font-medium text-white transition-colors hover:bg-green-700
focus:outline-none focus:ring-2 focus:ring-green-500
focus:ring-offset-2 disabled:opacity-50
"
>
{loading
? localize('com_auth_loading')
: localize(
mode === 'login'
? 'com_auth_passkey_login'
: 'com_auth_passkey_register',
: 'com_auth_passkey_register'
)}
</button>
</form>

{onBack && (
<div className="mt-4 text-center">
<button
Expand All @@ -194,7 +251,7 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
{localize(
mode === 'login'
? 'com_auth_back_to_login'
: 'com_auth_back_to_register',
: 'com_auth_back_to_register'
)}
</button>
</div>
Expand All @@ -203,19 +260,4 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ 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(/=+$/, '');
}
export default PasskeyAuth;

0 comments on commit 1e1b865

Please sign in to comment.