fix: working code + updated my package passport-simple-webauthn2

This commit is contained in:
Ruben Talstra 2025-02-12 21:49:39 +01:00
parent 1e1b865f4f
commit 8ea085ee25
Failed to extract signature
4 changed files with 119 additions and 161 deletions

View file

@ -96,7 +96,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-ldapauth": "^3.0.1", "passport-ldapauth": "^3.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"passport-simple-webauthn2": "^3.0.5", "passport-simple-webauthn2": "^3.2.0",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"tiktoken": "^1.0.15", "tiktoken": "^1.0.15",
"traverse": "^0.6.7", "traverse": "^0.6.7",

View file

@ -21,8 +21,8 @@ const AppService = require('./services/AppService');
const staticCache = require('./utils/staticCache'); const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex'); const noIndex = require('./middleware/noIndex');
const routes = require('./routes'); const routes = require('./routes');
const { WebAuthnStrategy } = require('passport-simple-webauthn2');
const { mongoUserStore, mongoChallengeStore } = require('~/cache'); const { mongoUserStore, mongoChallengeStore } = require('~/cache');
const { WebAuthnStrategy } = require('passport-simple-webauthn2');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {}; const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {};

View file

@ -3,7 +3,7 @@ import { useLocalize } from '~/hooks';
type PasskeyAuthProps = { type PasskeyAuthProps = {
mode: 'login' | 'register'; mode: 'login' | 'register';
onBack?: () => void; onBack?: () => void; // Optional callback to return to normal login/register view
}; };
const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => { const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
@ -11,47 +11,25 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fetchWithError = async (url: string, options: RequestInit) => { // --- PASSKEY LOGIN FLOW ---
const response = await fetch(url, options); async function handlePasskeyLogin() {
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) { if (!email) {
alert('Email is required for login.'); return alert('Email is required for login.');
return;
} }
setLoading(true); setLoading(true);
try { try {
// 1. Fetch login challenge const challengeResponse = await fetch(
const options = await fetchWithError(
`/webauthn/login?email=${encodeURIComponent(email)}`, `/webauthn/login?email=${encodeURIComponent(email)}`,
{ method: 'GET' }, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
); );
if (!challengeResponse.ok) {
// 2. Convert challenge & credential IDs const errorData = await challengeResponse.json();
throw new Error(errorData.error || 'Failed to get challenge');
}
const options = await challengeResponse.json();
options.challenge = base64URLToArrayBuffer(options.challenge); options.challenge = base64URLToArrayBuffer(options.challenge);
if (options.allowCredentials) { if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map((cred: any) => ({ options.allowCredentials = options.allowCredentials.map((cred: any) => ({
@ -59,49 +37,33 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
id: base64URLToArrayBuffer(cred.id), 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) { if (!credential) {
new Error('Failed to obtain credential'); throw new Error('Failed to obtain credential');
} }
const authenticationResponse = {
// 4. Build credential object id: credential.id,
const { id, rawId, response, type } = credential; rawId: arrayBufferToBase64URL(credential.rawId),
const authResponse = { type: credential.type,
id,
rawId: arrayBufferToBase64URL(rawId),
type,
response: { response: {
authenticatorData: arrayBufferToBase64URL( authenticatorData: arrayBufferToBase64URL((credential.response as any).authenticatorData),
(response as AuthenticatorAssertionResponse).authenticatorData clientDataJSON: arrayBufferToBase64URL((credential.response as any).clientDataJSON),
), signature: arrayBufferToBase64URL((credential.response as any).signature),
clientDataJSON: arrayBufferToBase64URL( userHandle: (credential.response as any).userHandle
(response as AuthenticatorAssertionResponse).clientDataJSON ? arrayBufferToBase64URL((credential.response as any).userHandle)
),
signature: arrayBufferToBase64URL(
(response as AuthenticatorAssertionResponse).signature
),
userHandle: (response as AuthenticatorAssertionResponse).userHandle
? arrayBufferToBase64URL(
(response as AuthenticatorAssertionResponse).userHandle!
)
: null, : null,
}, },
}; };
const loginCallbackResponse = await fetch('/webauthn/login', {
// 5. Send credential to server for verification
const result = await fetchWithError('/webauthn/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, credential: authResponse }), body: JSON.stringify({ email, credential: authenticationResponse }),
}); });
const result = await loginCallbackResponse.json();
if (result?.user) { if (result.user) {
window.location.href = '/'; window.location.href = '/';
} else { } else {
new Error(result?.error || 'Authentication failed'); throw new Error(result.error || 'Authentication failed');
} }
} catch (error: any) { } catch (error: any) {
console.error('Passkey login error:', error); console.error('Passkey login error:', error);
@ -109,26 +71,27 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
};
/**
* Passkey Registration Flow
*/
const handlePasskeyRegister = async () => {
if (!email) {
alert('Email is required for registration.');
return;
} }
// --- PASSKEY REGISTRATION FLOW ---
async function handlePasskeyRegister() {
if (!email) {
return alert('Email is required for registration.');
}
setLoading(true); setLoading(true);
try { try {
// 1. Fetch registration challenge const challengeResponse = await fetch(
const options = await fetchWithError(
`/webauthn/register?email=${encodeURIComponent(email)}`, `/webauthn/register?email=${encodeURIComponent(email)}`,
{ method: 'GET' }, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
); );
if (!challengeResponse.ok) {
// 2. Convert challenge & credential IDs const errorData = await challengeResponse.json();
throw new Error(errorData.error || 'Failed to get challenge');
}
const options = await challengeResponse.json();
options.challenge = base64URLToArrayBuffer(options.challenge); options.challenge = base64URLToArrayBuffer(options.challenge);
options.user.id = base64URLToArrayBuffer(options.user.id); options.user.id = base64URLToArrayBuffer(options.user.id);
if (options.excludeCredentials) { if (options.excludeCredentials) {
@ -137,41 +100,29 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
id: base64URLToArrayBuffer(cred.id), id: base64URLToArrayBuffer(cred.id),
})); }));
} }
// 3. Request credential creation
const credential = await navigator.credentials.create({ publicKey: options }); const credential = await navigator.credentials.create({ publicKey: options });
if (!credential) { if (!credential) {
new Error('Failed to create credential'); throw new Error('Failed to create credential');
} }
// 4. Build registration object
const { id, rawId, response, type } = credential;
const registrationResponse = { const registrationResponse = {
id, id: credential.id,
rawId: arrayBufferToBase64URL(rawId), rawId: arrayBufferToBase64URL(credential.rawId),
type, type: credential.type,
response: { response: {
clientDataJSON: arrayBufferToBase64URL( clientDataJSON: arrayBufferToBase64URL((credential.response as any).clientDataJSON),
(response as AuthenticatorAttestationResponse).clientDataJSON attestationObject: arrayBufferToBase64URL((credential.response as any).attestationObject),
),
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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, credential: registrationResponse }), body: JSON.stringify({ email, credential: registrationResponse }),
}); });
const result = await registerCallbackResponse.json();
if (result?.user) { if (result.user) {
window.location.href = '/login'; window.location.href = '/login';
} else { } else {
new Error(result?.error || 'Registration failed'); throw new Error(result.error || 'Registration failed');
} }
} catch (error: any) { } catch (error: any) {
console.error('Passkey registration error:', error); console.error('Passkey registration error:', error);
@ -179,7 +130,7 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -195,7 +146,7 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="relative mb-4"> <div className="relative mb-4">
<input <input
type="email" type="text"
id="passkey-email" id="passkey-email"
autoComplete="email" autoComplete="email"
aria-label={localize('com_auth_email')} aria-label={localize('com_auth_email')}
@ -211,37 +162,29 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
<label <label
htmlFor="passkey-email" htmlFor="passkey-email"
className=" className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform 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
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-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 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
peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600
" "
> >
{localize('com_auth_email_address')} {localize('com_auth_email_address')}
</label> </label>
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className=" 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"
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 {loading
? localize('com_auth_loading') ? localize('com_auth_loading')
: localize( : localize(
mode === 'login' mode === 'login'
? 'com_auth_passkey_login' ? 'com_auth_passkey_login'
: 'com_auth_passkey_register' : 'com_auth_passkey_register',
)} )}
</button> </button>
</form> </form>
{onBack && ( {onBack && (
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<button <button
@ -251,7 +194,7 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
{localize( {localize(
mode === 'login' mode === 'login'
? 'com_auth_back_to_login' ? 'com_auth_back_to_login'
: 'com_auth_back_to_register' : 'com_auth_back_to_register',
)} )}
</button> </button>
</div> </div>
@ -261,3 +204,18 @@ const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
}; };
export default PasskeyAuth; 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(/=+$/, '');
}

76
package-lock.json generated
View file

@ -110,7 +110,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-ldapauth": "^3.0.1", "passport-ldapauth": "^3.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"passport-simple-webauthn2": "^3.0.5", "passport-simple-webauthn2": "^3.2.0",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"tiktoken": "^1.0.15", "tiktoken": "^1.0.15",
"traverse": "^0.6.7", "traverse": "^0.6.7",
@ -954,6 +954,43 @@
} }
} }
}, },
"api/node_modules/passport-simple-webauthn2": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/passport-simple-webauthn2/-/passport-simple-webauthn2-3.2.0.tgz",
"integrity": "sha512-Y5wwa16SmUZpgBh+xSjwng/px+ocIZZ5dil1AO2zNwPSIYGl5nuKHf0QvzHQSHxxbO9txW5+JsqAmimL2eYoBw==",
"license": "MIT",
"dependencies": {
"@simplewebauthn/server": "^13.1.1",
"base64url": "^3.0.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"passport-strategy": "^1.0.0",
"redis": "^4.7.0",
"uuid": "^11.0.5",
"winston": "^3.17.0"
},
"engines": {
"node": ">=21"
},
"peerDependencies": {
"express": "^4.17.0",
"express-session": "^1.17.0",
"passport": "^0.6.0"
}
},
"api/node_modules/passport-simple-webauthn2/node_modules/uuid": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"api/node_modules/superagent": { "api/node_modules/superagent": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz",
@ -29548,43 +29585,6 @@
"url": "https://github.com/sponsors/jaredhanson" "url": "https://github.com/sponsors/jaredhanson"
} }
}, },
"node_modules/passport-simple-webauthn2": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/passport-simple-webauthn2/-/passport-simple-webauthn2-3.1.1.tgz",
"integrity": "sha512-FXJWXRNYUtD1At4nVCzDY0DFZ/3/VQEucHvNEA/zzwwRj11V+hAp/Z2Vc1NmQrCGGdRktzZlHt9OIZHwqim13Q==",
"license": "MIT",
"dependencies": {
"@simplewebauthn/server": "^13.1.1",
"base64url": "^3.0.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"passport-strategy": "^1.0.0",
"redis": "^4.7.0",
"uuid": "^11.0.5",
"winston": "^3.17.0"
},
"engines": {
"node": ">=21"
},
"peerDependencies": {
"express": "^4.17.0",
"express-session": "^1.17.0",
"passport": "^0.6.0"
}
},
"node_modules/passport-simple-webauthn2/node_modules/uuid": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/passport-strategy": { "node_modules/passport-strategy": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",