fix: disallow literal string

This commit is contained in:
Ruben Talstra 2025-02-12 22:16:05 +01:00
parent 05a4f6cc45
commit a0c4ddaf9e
Failed to extract signature
3 changed files with 137 additions and 63 deletions

View file

@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { useLocalize } from '~/hooks';
import { TranslationKeys, 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,11 +11,85 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
// Utility for showing errors using localized keys
const alertError = (key: TranslationKeys, error: any) => {
console.error(`${localize(key)} error:`, error);
alert(
`${localize(key)}: ${error.message}. ${localize('com_auth_passkey_try_again')}`
);
};
// Convert login challenge options from the server
const processLoginOptions = (options: any) => {
options.challenge = base64URLToArrayBuffer(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map((cred: any) => ({
...cred,
id: base64URLToArrayBuffer(cred.id),
}));
}
return options;
};
// Convert registration challenge options from the server
const processRegistrationOptions = (options: any) => {
options.challenge = base64URLToArrayBuffer(options.challenge);
options.user.id = base64URLToArrayBuffer(options.user.id);
if (options.excludeCredentials) {
options.excludeCredentials = options.excludeCredentials.map((cred: any) => ({
...cred,
id: base64URLToArrayBuffer(cred.id),
}));
}
return options;
};
// Format the authentication response from navigator.credentials.get()
const getAuthenticationResponse = (credential: PublicKeyCredential) => ({
id: credential.id,
rawId: arrayBufferToBase64URL(credential.rawId),
type: credential.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)
: null,
},
});
// Format the registration response from navigator.credentials.create()
const getRegistrationResponse = (credential: PublicKeyCredential) => ({
id: credential.id,
rawId: arrayBufferToBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: arrayBufferToBase64URL(
(credential.response as any).clientDataJSON
),
attestationObject: arrayBufferToBase64URL(
(credential.response as any).attestationObject
),
},
});
// --- PASSKEY LOGIN FLOW ---
async function handlePasskeyLogin() {
if (!email) {
// (You may wish to replace this literal with a localized string if available.)
return alert('Email is required for login.');
}
if (typeof PublicKeyCredential === 'undefined') {
alert(localize('com_auth_passkey_not_supported'));
return;
}
setLoading(true);
try {
const challengeResponse = await fetch(
@ -23,37 +97,25 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
}
);
if (!challengeResponse.ok) {
const errorData = await challengeResponse.json();
throw new Error(errorData.error || 'Failed to get challenge');
throw new Error(
errorData.error || localize('com_auth_passkey_error')
);
}
const options = await challengeResponse.json();
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 });
let options = await challengeResponse.json();
options = processLoginOptions(options);
const credential = (await navigator.credentials.get({
publicKey: options,
})) as PublicKeyCredential;
if (!credential) {
throw new Error('Failed to obtain credential');
throw new Error(localize('com_auth_passkey_no_credentials'));
}
const authenticationResponse = {
id: credential.id,
rawId: arrayBufferToBase64URL(credential.rawId),
type: credential.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)
: null,
},
};
const authenticationResponse = getAuthenticationResponse(credential);
const loginCallbackResponse = await fetch('/webauthn/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -61,13 +123,15 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
});
const result = await loginCallbackResponse.json();
if (result.user) {
alert(localize('com_auth_passkey_login_success'));
window.location.href = '/';
} else {
throw new Error(result.error || 'Authentication failed');
throw new Error(
result.error || localize('com_auth_passkey_error')
);
}
} catch (error: any) {
console.error('Passkey login error:', error);
alert('Authentication failed: ' + error.message);
alertError('com_auth_passkey_failed', error);
} finally {
setLoading(false);
}
@ -76,8 +140,13 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
// --- PASSKEY REGISTRATION FLOW ---
async function handlePasskeyRegister() {
if (!email) {
// (You may wish to replace this literal with a localized string if available.)
return alert('Email is required for registration.');
}
if (typeof PublicKeyCredential === 'undefined') {
alert(localize('com_auth_passkey_not_supported'));
return;
}
setLoading(true);
try {
const challengeResponse = await fetch(
@ -85,34 +154,25 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
}
);
if (!challengeResponse.ok) {
const errorData = await challengeResponse.json();
throw new Error(errorData.error || 'Failed to get challenge');
throw new Error(
errorData.error || localize('com_auth_passkey_error')
);
}
const options = await challengeResponse.json();
options.challenge = base64URLToArrayBuffer(options.challenge);
options.user.id = base64URLToArrayBuffer(options.user.id);
if (options.excludeCredentials) {
options.excludeCredentials = options.excludeCredentials.map((cred: any) => ({
...cred,
id: base64URLToArrayBuffer(cred.id),
}));
}
const credential = await navigator.credentials.create({ publicKey: options });
let options = await challengeResponse.json();
options = processRegistrationOptions(options);
const credential = (await navigator.credentials.create({
publicKey: options,
})) as PublicKeyCredential;
if (!credential) {
throw new Error('Failed to create credential');
throw new Error(localize('com_auth_passkey_create_error'));
}
const registrationResponse = {
id: credential.id,
rawId: arrayBufferToBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: arrayBufferToBase64URL((credential.response as any).clientDataJSON),
attestationObject: arrayBufferToBase64URL((credential.response as any).attestationObject),
},
};
const registrationResponse = getRegistrationResponse(credential);
const registerCallbackResponse = await fetch('/webauthn/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -120,13 +180,15 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
});
const result = await registerCallbackResponse.json();
if (result.user) {
alert(localize('com_auth_passkey_register_success'));
window.location.href = '/login';
} else {
throw new Error(result.error || 'Registration failed');
throw new Error(
result.error || localize('com_auth_passkey_error')
);
}
} catch (error: any) {
console.error('Passkey registration error:', error);
alert('Registration failed: ' + error.message);
alertError('com_auth_passkey_registration_failed', error);
} finally {
setLoading(false);
}
@ -180,8 +242,8 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
? localize('com_auth_loading')
: localize(
mode === 'login'
? 'com_auth_passkey_login'
: 'com_auth_passkey_register',
? 'com_auth_passkey_login_success'
: 'com_auth_passkey_register_success'
)}
</button>
</form>

View file

@ -41,16 +41,16 @@ export default function PassKeys() {
{user.passkeys.map((passkey: TPasskey) => (
<div key={passkey.id} className="rounded-lg border p-3 bg-gray-50 dark:bg-gray-800">
<p className="text-sm">
<strong>ID:</strong> {passkey.id}
<strong>{localize('com_nav_settings_passkey_label_id')}</strong> {passkey.id}
</p>
<p className="text-sm break-all">
<strong>Public Key:</strong> {Buffer.from(passkey.publicKey).toString('base64')}
<strong>{localize('com_nav_settings_passkey_label_public_key')}</strong> {Buffer.from(passkey.publicKey).toString('base64')}
</p>
<p className="text-sm">
<strong>Usage Counter:</strong> {passkey.counter}
<strong>{localize('com_nav_settings_passkey_label_usage_counter')}</strong> {passkey.counter}
</p>
<p className="text-sm">
<strong>Transports:</strong> {passkey.transports.length > 0 ? passkey.transports.join(', ') : 'None'}
<strong>{localize('com_nav_settings_passkey_label_transports')}</strong> {passkey.transports.length > 0 ? passkey.transports.join(', ') : localize('com_nav_settings_passkey_none')}
</p>
</div>
))}

View file

@ -779,12 +779,24 @@
"com_ui_zoom": "Zoom",
"com_user_message": "You",
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
"com_auth_passkey_login": "Login with Passkey",
"com_auth_passkey_register": "Register with Passkey",
"com_auth_loading": "Loading...",
"com_auth_back_to_register": "Back to registration",
"com_nav_passkeys": "Passkeys",
"com_nav_view_passkeys": "View Passkeys",
"com_auth_passkey_failed": "Authentication failed",
"com_auth_passkey_registration_failed": "Registration failed",
"com_auth_passkey_error": "Passkey authentication error",
"com_auth_passkey_create_error": "Failed to create passkey",
"com_auth_passkey_not_supported": "Passkeys are not supported on this device",
"com_auth_passkey_no_credentials": "No passkey credentials found",
"com_auth_passkey_register_success": "Successfully registered with passkey",
"com_auth_passkey_login_success": "Successfully logged in with passkey",
"com_auth_passkey_try_again": "Please try again",
"com_nav_settings_passkey_label_id": "ID:",
"com_nav_settings_passkey_label_public_key": "Public Key:",
"com_nav_settings_passkey_label_usage_counter": "Usage Counter:",
"com_nav_settings_passkey_label_transports": "Transports:",
"com_nav_settings_passkey_none": "None",
"com_ui_no_data": "something needs to go here. was empty",
"com_files_table": "something needs to go here. was empty",
"com_ui_global_group": "something needs to go here. was empty",