mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 18:30:15 +01:00
refactor: PasskeyAuth.tsx
This commit is contained in:
parent
1ab5bc425d
commit
1e1b865f4f
1 changed files with 124 additions and 82 deletions
|
|
@ -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<PasskeyAuthProps> = ({ mode, onBack }) => {
|
||||
|
|
@ -11,25 +11,47 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
|
|||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// --- PASSKEY LOGIN FLOW ---
|
||||
async function handlePasskeyLogin() {
|
||||
if (!email) {
|
||||
return alert(localize('Email is required for login.'));
|
||||
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) {
|
||||
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<PasskeyAuthProps> = ({ 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<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();
|
||||
|
|
@ -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')}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue