diff --git a/api/package.json b/api/package.json index cb1239a946..d5d9540762 100644 --- a/api/package.json +++ b/api/package.json @@ -96,7 +96,7 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", - "passport-simple-webauthn2": "^3.0.5", + "passport-simple-webauthn2": "^3.2.0", "sharp": "^0.32.6", "tiktoken": "^1.0.15", "traverse": "^0.6.7", diff --git a/api/server/index.js b/api/server/index.js index 97fd21d345..a27e6b4008 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -21,8 +21,8 @@ const AppService = require('./services/AppService'); const staticCache = require('./utils/staticCache'); const noIndex = require('./middleware/noIndex'); const routes = require('./routes'); -const { WebAuthnStrategy } = require('passport-simple-webauthn2'); const { mongoUserStore, mongoChallengeStore } = require('~/cache'); +const { WebAuthnStrategy } = require('passport-simple-webauthn2'); const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {}; diff --git a/client/src/components/Auth/PasskeyAuth.tsx b/client/src/components/Auth/PasskeyAuth.tsx index 35cc8dd9ab..8db0ca296b 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; + onBack?: () => void; // Optional callback to return to normal login/register view }; const PasskeyAuth: React.FC = ({ mode, onBack }) => { @@ -11,47 +11,25 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); - 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 () => { + // --- PASSKEY LOGIN FLOW --- + async function handlePasskeyLogin() { if (!email) { - alert('Email is required for login.'); - return; + return alert('Email is required for login.'); } - setLoading(true); try { - // 1. Fetch login challenge - const options = await fetchWithError( + const challengeResponse = await fetch( `/webauthn/login?email=${encodeURIComponent(email)}`, - { method: 'GET' }, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, ); - - // 2. Convert challenge & credential IDs + if (!challengeResponse.ok) { + const errorData = await challengeResponse.json(); + throw new Error(errorData.error || 'Failed to get challenge'); + } + const options = await challengeResponse.json(); options.challenge = base64URLToArrayBuffer(options.challenge); if (options.allowCredentials) { options.allowCredentials = options.allowCredentials.map((cred: any) => ({ @@ -59,49 +37,33 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { id: base64URLToArrayBuffer(cred.id), })); } - - // 3. Request credential - const credential = await navigator.credentials.create({ publicKey: options }); - + const credential = await navigator.credentials.get({ publicKey: options }); if (!credential) { - new Error('Failed to obtain credential'); + throw new Error('Failed to obtain credential'); } - - // 4. Build credential object - const { id, rawId, response, type } = credential; - const authResponse = { - id, - rawId: arrayBufferToBase64URL(rawId), - type, + const authenticationResponse = { + id: credential.id, + rawId: arrayBufferToBase64URL(credential.rawId), + type: credential.type, response: { - 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! - ) + 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, }, }; - - // 5. Send credential to server for verification - const result = await fetchWithError('/webauthn/login', { + const loginCallbackResponse = await fetch('/webauthn/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, credential: authResponse }), + body: JSON.stringify({ email, credential: authenticationResponse }), }); - - if (result?.user) { + const result = await loginCallbackResponse.json(); + if (result.user) { window.location.href = '/'; } else { - new Error(result?.error || 'Authentication failed'); + throw new Error(result.error || 'Authentication failed'); } } catch (error: any) { console.error('Passkey login error:', error); @@ -109,26 +71,27 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { } finally { setLoading(false); } - }; + } - /** - * Passkey Registration Flow - */ - const handlePasskeyRegister = async () => { + // --- PASSKEY REGISTRATION FLOW --- + async function handlePasskeyRegister() { if (!email) { - alert('Email is required for registration.'); - return; + return alert('Email is required for registration.'); } - setLoading(true); try { - // 1. Fetch registration challenge - const options = await fetchWithError( + const challengeResponse = await fetch( `/webauthn/register?email=${encodeURIComponent(email)}`, - { method: 'GET' }, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, ); - - // 2. Convert challenge & credential IDs + if (!challengeResponse.ok) { + 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.user.id = base64URLToArrayBuffer(options.user.id); if (options.excludeCredentials) { @@ -137,41 +100,29 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { id: base64URLToArrayBuffer(cred.id), })); } - - // 3. Request credential creation const credential = await navigator.credentials.create({ publicKey: options }); - 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 = { - id, - rawId: arrayBufferToBase64URL(rawId), - type, + id: credential.id, + rawId: arrayBufferToBase64URL(credential.rawId), + type: credential.type, response: { - clientDataJSON: arrayBufferToBase64URL( - (response as AuthenticatorAttestationResponse).clientDataJSON - ), - attestationObject: arrayBufferToBase64URL( - (response as AuthenticatorAttestationResponse).attestationObject - ), + clientDataJSON: arrayBufferToBase64URL((credential.response as any).clientDataJSON), + attestationObject: arrayBufferToBase64URL((credential.response as any).attestationObject), }, }; - - // 5. Send credential to server for verification - const result = await fetchWithError('/webauthn/register', { + const registerCallbackResponse = await fetch('/webauthn/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, credential: registrationResponse }), }); - - if (result?.user) { + const result = await registerCallbackResponse.json(); + if (result.user) { window.location.href = '/login'; } else { - new Error(result?.error || 'Registration failed'); + throw new Error(result.error || 'Registration failed'); } } catch (error: any) { console.error('Passkey registration error:', error); @@ -179,7 +130,7 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { } finally { setLoading(false); } - }; + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -195,7 +146,7 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => {
= ({ mode, onBack }) => {
-
- {onBack && (
@@ -260,4 +203,19 @@ const PasskeyAuth: React.FC = ({ mode, onBack }) => { ); }; -export default PasskeyAuth; \ No newline at end of file +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 diff --git a/package-lock.json b/package-lock.json index 76740c2e38..42c496f2fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -110,7 +110,7 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", - "passport-simple-webauthn2": "^3.0.5", + "passport-simple-webauthn2": "^3.2.0", "sharp": "^0.32.6", "tiktoken": "^1.0.15", "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": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", @@ -29548,43 +29585,6 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",