From 1cb1c9196d885bbd1ceffba597b1d8c6a843f8a2 Mon Sep 17 00:00:00 2001 From: Ruben Talstra Date: Wed, 12 Feb 2025 20:40:29 +0100 Subject: [PATCH] =?UTF-8?q?WIP=20=F0=9F=94=90=20feat:=20PassKey=20(#5606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added PassKey authentication. * fixed issue with test :) * Delete client/src/components/Auth/AuthLayout.tsx * fix: conflicted issue --- .env.example | 4 + .gitignore | 5 +- api/cache/index.js | 4 +- api/cache/mongoChallengeStore.js | 35 +++ api/cache/mongoUserStore.js | 55 +++++ api/models/ChallengeStore.js | 6 + api/models/schema/challengeSchema.js | 22 ++ api/models/schema/userSchema.js | 11 + api/server/index.js | 20 ++ api/server/routes/authWebAuthn.js | 44 ++++ api/server/routes/config.js | 1 + api/server/routes/index.js | 2 + client/src/components/Auth/PasskeyAuth.tsx | 221 ++++++++++++++++++ .../src/components/Auth/SocialLoginRender.tsx | 54 ++++- .../Nav/SettingsTabs/Account/Account.tsx | 4 + .../Nav/SettingsTabs/Account/PassKeys.tsx | 71 ++++++ client/src/components/svg/PasskeyIcon.tsx | 12 + client/src/components/svg/index.ts | 1 + packages/data-provider/src/config.ts | 1 + packages/data-provider/src/types.ts | 8 + 20 files changed, 569 insertions(+), 12 deletions(-) create mode 100644 api/cache/mongoChallengeStore.js create mode 100644 api/cache/mongoUserStore.js create mode 100644 api/models/ChallengeStore.js create mode 100644 api/models/schema/challengeSchema.js create mode 100644 api/server/routes/authWebAuthn.js create mode 100644 client/src/components/Auth/PasskeyAuth.tsx create mode 100644 client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx create mode 100644 client/src/components/svg/PasskeyIcon.tsx diff --git a/.env.example b/.env.example index d87021ea4b..d5c4250d67 100644 --- a/.env.example +++ b/.env.example @@ -405,6 +405,10 @@ APPLE_KEY_ID= APPLE_PRIVATE_KEY_PATH= APPLE_CALLBACK_URL=/oauth/apple/callback +# PassKeys +PASSKEY_ENABLED=true +RP_ID=localhost + # OpenID OPENID_CLIENT_ID= OPENID_CLIENT_SECRET= diff --git a/.gitignore b/.gitignore index a80c13a745..d666501760 100644 --- a/.gitignore +++ b/.gitignore @@ -105,4 +105,7 @@ auth.json uploads/ # owner -release/ \ No newline at end of file +release/ + +# Apple Private Key +*.p8 \ No newline at end of file diff --git a/api/cache/index.js b/api/cache/index.js index bb1e774183..1e76a8ed5f 100644 --- a/api/cache/index.js +++ b/api/cache/index.js @@ -1,5 +1,7 @@ const keyvFiles = require('./keyvFiles'); const getLogStores = require('./getLogStores'); const logViolation = require('./logViolation'); +const mongoUserStore = require('./mongoUserStore'); +const mongoChallengeStore = require('./mongoChallengeStore'); -module.exports = { ...keyvFiles, getLogStores, logViolation }; +module.exports = { ...keyvFiles, getLogStores, logViolation, mongoUserStore, mongoChallengeStore }; \ No newline at end of file diff --git a/api/cache/mongoChallengeStore.js b/api/cache/mongoChallengeStore.js new file mode 100644 index 0000000000..b99393d569 --- /dev/null +++ b/api/cache/mongoChallengeStore.js @@ -0,0 +1,35 @@ +const ChallengeStore = require('~/models/ChallengeStore'); + +class MongoChallengeStore { + async get(userId) { + try { + const challenge = await ChallengeStore.findOne({ userId }).lean().exec(); + return challenge ? challenge.challenge : undefined; + } catch (error) { + console.error(`❌ Error fetching challenge for userId ${userId}:`, error); + return undefined; + } + } + + async save(userId, challenge) { + try { + await ChallengeStore.findOneAndUpdate( + { userId }, + { challenge, createdAt: new Date() }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ).exec(); + } catch (error) { + console.error(`❌ Error saving challenge for userId ${userId}:`, error); + } + } + + async delete(userId) { + try { + await ChallengeStore.deleteOne({ userId }).exec(); + } catch (error) { + console.error(`❌ Error deleting challenge for userId ${userId}:`, error); + } + } +} + +module.exports = MongoChallengeStore; \ No newline at end of file diff --git a/api/cache/mongoUserStore.js b/api/cache/mongoUserStore.js new file mode 100644 index 0000000000..2ab070a527 --- /dev/null +++ b/api/cache/mongoUserStore.js @@ -0,0 +1,55 @@ +const User = require('~/models'); + +class MongoUserStore { + async get(identifier, byID = false) { + let user; + if (byID) { + user = await User.getUserById(identifier); + } else { + user = await User.findUser({ email: identifier }); + } + if (user) { + return { + id: user._id.toString(), + email: user.email, + passkeys: user.passkeys, + }; + } + return undefined; + } + + async save(user) { + if (!user.id) { + const createdUser = await User.createUser( + { + email: user.email, + username: user.email, + passkeys: user.passkeys, + }, + /* disableTTL */ true, + /* returnUser */ true, + ); + return { + id: createdUser._id.toString(), + email: createdUser.email, + passkeys: createdUser.passkeys, + }; + } else { + const updatedUser = await User.updateUser(user.id, { + email: user.email, + username: user.email, + passkeys: user.passkeys, + }); + if (!updatedUser) { + throw new Error('Failed to update user'); + } + return { + id: updatedUser._id.toString(), + email: updatedUser.email, + passkeys: updatedUser.passkeys, + }; + } + } +} + +module.exports = MongoUserStore; \ No newline at end of file diff --git a/api/models/ChallengeStore.js b/api/models/ChallengeStore.js new file mode 100644 index 0000000000..71308373f1 --- /dev/null +++ b/api/models/ChallengeStore.js @@ -0,0 +1,6 @@ +const mongoose = require('mongoose'); +const challengeSchema = require('~/models/schema/challengeSchema'); + +const ChallengeStore = mongoose.model('Challenge', challengeSchema); + +module.exports = ChallengeStore; \ No newline at end of file diff --git a/api/models/schema/challengeSchema.js b/api/models/schema/challengeSchema.js new file mode 100644 index 0000000000..a3d6dc9039 --- /dev/null +++ b/api/models/schema/challengeSchema.js @@ -0,0 +1,22 @@ +const mongoose = require('mongoose'); + +const challengeSchema = mongoose.Schema({ + userId: { + type: String, + required: true, + unique: true, + }, + challenge: { + type: String, + required: true, + }, + createdAt: { + type: Date, + default: Date.now, + index: { + expires: '5m', + }, + }, +}); + +module.exports = challengeSchema; \ No newline at end of file diff --git a/api/models/schema/userSchema.js b/api/models/schema/userSchema.js index f586553367..1a3e23a003 100644 --- a/api/models/schema/userSchema.js +++ b/api/models/schema/userSchema.js @@ -39,6 +39,13 @@ const Session = mongoose.Schema({ }, }); +const passkeySchema = mongoose.Schema({ + id: { type: String, required: true }, + publicKey: { type: Buffer, required: true }, + counter: { type: Number, default: 0 }, + transports: { type: [String], default: [] }, +}); + /** @type {MongooseSchema} */ const userSchema = mongoose.Schema( { @@ -117,6 +124,10 @@ const userSchema = mongoose.Schema( unique: true, sparse: true, }, + passkeys: { + type: [passkeySchema], + default: [], + }, plugins: { type: Array, default: [], diff --git a/api/server/index.js b/api/server/index.js index 30d36d9a9f..97fd21d345 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -21,6 +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 { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {}; @@ -77,11 +79,29 @@ const startServer = async () => { passport.use(ldapLogin); } + /* Passkey (WebAuthn) Strategy */ + if (process.env.PASSKEY_ENABLED) { + + const userStore = new mongoUserStore(); + const challengeStore = new mongoChallengeStore(); + + passport.use( + new WebAuthnStrategy({ + rpID: process.env.RP_ID || 'localhost', + rpName: process.env.APP_TITLE || 'LibreChat', + userStore, + challengeStore, + debug: true, + }), + ); + } + if (isEnabled(ALLOW_SOCIAL_LOGIN)) { configureSocialLogins(app); } app.use('/oauth', routes.oauth); + app.use('/webauthn', routes.authWebAuthn); /* API Endpoints */ app.use('/api/auth', routes.auth); app.use('/api/actions', routes.actions); diff --git a/api/server/routes/authWebAuthn.js b/api/server/routes/authWebAuthn.js new file mode 100644 index 0000000000..0ed42e0a25 --- /dev/null +++ b/api/server/routes/authWebAuthn.js @@ -0,0 +1,44 @@ +const express = require('express'); +const passport = require('passport'); +const { setAuthTokens } = require('~/server/services/AuthService'); +const router = express.Router(); + +router.get( + '/register', + passport.authenticate('webauthn', { session: false }), + (req, res) => { + res.json(req.user); + }, +); + +router.post( + '/register', + passport.authenticate('webauthn', { session: false, failureRedirect: '/login' }), + (req, res) => { + res.json({ user: req.user }); + }, +); + +router.get( + '/login', + passport.authenticate('webauthn', { session: false }), + (req, res) => { + res.json(req.user); + }, +); + +router.post( + '/login', + passport.authenticate('webauthn', { session: false, failureRedirect: '/login' }), + async (req, res) => { + try { + const token = await setAuthTokens(req.user.id, res); + res.status(200).json({ token, user: req.user }); + } catch (err) { + console.error('[WebAuthn Login Callback]', err); + res.status(500).json({ message: 'Something went wrong during login' }); + } + }, +); + +module.exports = router; \ No newline at end of file diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 705a1d3cb1..006fe6dc85 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -51,6 +51,7 @@ router.get('/', async function (req, res) { !!process.env.APPLE_TEAM_ID && !!process.env.APPLE_KEY_ID && !!process.env.APPLE_PRIVATE_KEY_PATH, + passkeyLoginEnabled : !!process.env.PASSKEY_ENABLED && !!process.env.RP_ID, openidLoginEnabled: !!process.env.OPENID_CLIENT_ID && !!process.env.OPENID_CLIENT_SECRET && diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 4b34029c7b..ed3e9a568d 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -1,3 +1,4 @@ +const authWebAuthn = require('./authWebAuthn'); const assistants = require('./assistants'); const categories = require('./categories'); const tokenizer = require('./tokenizer'); @@ -55,5 +56,6 @@ module.exports = { assistants, categories, staticRoute, + authWebAuthn, banner, }; diff --git a/client/src/components/Auth/PasskeyAuth.tsx b/client/src/components/Auth/PasskeyAuth.tsx new file mode 100644 index 0000000000..fa127e2246 --- /dev/null +++ b/client/src/components/Auth/PasskeyAuth.tsx @@ -0,0 +1,221 @@ +import React, { useState } from 'react'; +import { useLocalize } from '~/hooks'; + +type PasskeyAuthProps = { + mode: 'login' | 'register'; + onBack?: () => void; // Optional callback to return to normal login/register view +}; + +const PasskeyAuth: React.FC = ({ mode, onBack }) => { + const localize = useLocalize(); + 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.')); + } + setLoading(true); + try { + const challengeResponse = await fetch( + `/webauthn/login?email=${encodeURIComponent(email)}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ); + 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) => ({ + ...cred, + id: base64URLToArrayBuffer(cred.id), + })); + } + const credential = await navigator.credentials.get({ publicKey: options }); + if (!credential) { + throw new Error('Failed to obtain credential'); + } + 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 loginCallbackResponse = await fetch('/webauthn/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, credential: authenticationResponse }), + }); + const result = await loginCallbackResponse.json(); + if (result.user) { + window.location.href = '/'; + } else { + throw new Error(result.error || 'Authentication failed'); + } + } catch (error: any) { + console.error('Passkey login error:', error); + alert(localize('Authentication failed: ') + error.message); + } finally { + setLoading(false); + } + } + + // --- PASSKEY REGISTRATION FLOW --- + async function handlePasskeyRegister() { + if (!email) { + return alert(localize('Email is required for registration.')); + } + setLoading(true); + try { + const challengeResponse = await fetch( + `/webauthn/register?email=${encodeURIComponent(email)}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ); + 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) { + options.excludeCredentials = options.excludeCredentials.map((cred: any) => ({ + ...cred, + id: base64URLToArrayBuffer(cred.id), + })); + } + const credential = await navigator.credentials.create({ publicKey: options }); + if (!credential) { + throw new Error('Failed to create credential'); + } + 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 registerCallbackResponse = await fetch('/webauthn/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, credential: registrationResponse }), + }); + const result = await registerCallbackResponse.json(); + if (result.user) { + window.location.href = '/login'; + } else { + throw new Error(result.error || 'Registration failed'); + } + } catch (error: any) { + console.error('Passkey registration error:', error); + alert(localize('Registration failed: ') + error.message); + } finally { + setLoading(false); + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (mode === 'login') { + await handlePasskeyLogin(); + } else { + await handlePasskeyRegister(); + } + }; + + return ( +
+
+
+ setEmail(e.target.value)} + required + className=" + webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light + bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none + " + placeholder=" " + /> + +
+ +
+ {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(/=+$/, ''); +} \ No newline at end of file diff --git a/client/src/components/Auth/SocialLoginRender.tsx b/client/src/components/Auth/SocialLoginRender.tsx index 70c0a65b63..50f6276941 100644 --- a/client/src/components/Auth/SocialLoginRender.tsx +++ b/client/src/components/Auth/SocialLoginRender.tsx @@ -1,22 +1,36 @@ -import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components'; - +import { + GoogleIcon, + FacebookIcon, + OpenIDIcon, + GithubIcon, + DiscordIcon, + AppleIcon, + PasskeyIcon, +} from '~/components'; import SocialButton from './SocialButton'; - import { useLocalize } from '~/hooks'; - import { TStartupConfig } from 'librechat-data-provider'; +import React from 'react'; -function SocialLoginRender({ - startupConfig, -}: { +type SocialLoginRenderProps = { startupConfig: TStartupConfig | null | undefined; -}) { + mode: 'login' | 'register'; + onPasskeyClick?: () => void; +}; + +function SocialLoginRender({ startupConfig, mode, onPasskeyClick }: SocialLoginRenderProps) { const localize = useLocalize(); if (!startupConfig) { return null; } + // Compute the passkey label based on mode. + const passkeyLabel = + mode === 'register' + ? localize('com_auth_passkey_register') + : localize('com_auth_passkey_login'); + const providerComponents = { discord: startupConfig.discordLoginEnabled && (
- Or + Or
@@ -107,10 +121,30 @@ function SocialLoginRender({ )}
{startupConfig.socialLogins?.map((provider) => providerComponents[provider] || null)} + {startupConfig.passkeyLoginEnabled && ( + + + )}
) ); } -export default SocialLoginRender; +export default SocialLoginRender; \ No newline at end of file diff --git a/client/src/components/Nav/SettingsTabs/Account/Account.tsx b/client/src/components/Nav/SettingsTabs/Account/Account.tsx index 374a6b996e..d6e9c55b79 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Account.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Account.tsx @@ -2,6 +2,7 @@ import React from 'react'; import DisplayUsernameMessages from './DisplayUsernameMessages'; import DeleteAccount from './DeleteAccount'; import Avatar from './Avatar'; +import PassKeys from './PassKeys'; function Account() { return ( @@ -15,6 +16,9 @@ function Account() {
+
+ +
); } diff --git a/client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx b/client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx new file mode 100644 index 0000000000..e1242492cb --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import { Button, Label } from '~/components/ui'; +import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '~/components'; +import { useLocalize } from '~/hooks'; +import { useAuthContext } from '~/hooks/AuthContext'; +import type { TPasskey } from 'librechat-data-provider'; + +export default function PassKeys() { + const localize = useLocalize(); + const { user } = useAuthContext(); + const [isPasskeyModalOpen, setPasskeyModalOpen] = useState(false); + + if (!user?.passkeys?.length) { + return null; // Don't render if no passkeys + } + + return ( + <> +
+
+ +
+ +
+ + {/* Passkey Modal */} + + + + + {localize('com_nav_passkeys')} + + +
+ {user.passkeys.map((passkey: TPasskey) => ( +
+

+ ID: {passkey.id} +

+

+ Public Key: {Buffer.from(passkey.publicKey).toString('base64')} +

+

+ Usage Counter: {passkey.counter} +

+

+ Transports: {passkey.transports.length > 0 ? passkey.transports.join(', ') : 'None'} +

+
+ ))} +
+
+ +
+
+
+ + ); +} \ No newline at end of file diff --git a/client/src/components/svg/PasskeyIcon.tsx b/client/src/components/svg/PasskeyIcon.tsx new file mode 100644 index 0000000000..1c47aa72dd --- /dev/null +++ b/client/src/components/svg/PasskeyIcon.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export default function PasskeyIcon() { + return ( + + + + ); +} \ No newline at end of file diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts index 745c5210bd..6d49968b3c 100644 --- a/client/src/components/svg/index.ts +++ b/client/src/components/svg/index.ts @@ -23,6 +23,7 @@ export { default as OpenIDIcon } from './OpenIDIcon'; export { default as GithubIcon } from './GithubIcon'; export { default as DiscordIcon } from './DiscordIcon'; export { default as AppleIcon } from './AppleIcon'; +export { default as PasskeyIcon } from './PasskeyIcon'; export { default as AnthropicIcon } from './AnthropicIcon'; export { default as SendIcon } from './SendIcon'; export { default as LinkIcon } from './LinkIcon'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 887d466d1b..8007907d13 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -482,6 +482,7 @@ export type TStartupConfig = { googleLoginEnabled: boolean; openidLoginEnabled: boolean; appleLoginEnabled: boolean; + passkeyLoginEnabled: boolean; openidLabel: string; openidImageUrl: string; /** LDAP Auth Configuration */ diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 6d9cd87c88..a2d4e98a38 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -99,6 +99,13 @@ export type TError = { }; }; +export type TPasskey = { + id: string; + publicKey: Buffer; + counter: number; + transports: string[]; +}; + export type TUser = { id: string; username: string; @@ -108,6 +115,7 @@ export type TUser = { role: string; provider: string; plugins?: string[]; + passkeys?: TPasskey[]; createdAt: string; updatedAt: string; };