mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 09:50:15 +01:00
WIP 🔐 feat: PassKey (#5606)
* added PassKey authentication. * fixed issue with test :) * Delete client/src/components/Auth/AuthLayout.tsx * fix: conflicted issue
This commit is contained in:
parent
2a506df443
commit
1cb1c9196d
20 changed files with 569 additions and 12 deletions
|
|
@ -405,6 +405,10 @@ APPLE_KEY_ID=
|
||||||
APPLE_PRIVATE_KEY_PATH=
|
APPLE_PRIVATE_KEY_PATH=
|
||||||
APPLE_CALLBACK_URL=/oauth/apple/callback
|
APPLE_CALLBACK_URL=/oauth/apple/callback
|
||||||
|
|
||||||
|
# PassKeys
|
||||||
|
PASSKEY_ENABLED=true
|
||||||
|
RP_ID=localhost
|
||||||
|
|
||||||
# OpenID
|
# OpenID
|
||||||
OPENID_CLIENT_ID=
|
OPENID_CLIENT_ID=
|
||||||
OPENID_CLIENT_SECRET=
|
OPENID_CLIENT_SECRET=
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -106,3 +106,6 @@ uploads/
|
||||||
|
|
||||||
# owner
|
# owner
|
||||||
release/
|
release/
|
||||||
|
|
||||||
|
# Apple Private Key
|
||||||
|
*.p8
|
||||||
4
api/cache/index.js
vendored
4
api/cache/index.js
vendored
|
|
@ -1,5 +1,7 @@
|
||||||
const keyvFiles = require('./keyvFiles');
|
const keyvFiles = require('./keyvFiles');
|
||||||
const getLogStores = require('./getLogStores');
|
const getLogStores = require('./getLogStores');
|
||||||
const logViolation = require('./logViolation');
|
const logViolation = require('./logViolation');
|
||||||
|
const mongoUserStore = require('./mongoUserStore');
|
||||||
|
const mongoChallengeStore = require('./mongoChallengeStore');
|
||||||
|
|
||||||
module.exports = { ...keyvFiles, getLogStores, logViolation };
|
module.exports = { ...keyvFiles, getLogStores, logViolation, mongoUserStore, mongoChallengeStore };
|
||||||
35
api/cache/mongoChallengeStore.js
vendored
Normal file
35
api/cache/mongoChallengeStore.js
vendored
Normal file
|
|
@ -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;
|
||||||
55
api/cache/mongoUserStore.js
vendored
Normal file
55
api/cache/mongoUserStore.js
vendored
Normal file
|
|
@ -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;
|
||||||
6
api/models/ChallengeStore.js
Normal file
6
api/models/ChallengeStore.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const challengeSchema = require('~/models/schema/challengeSchema');
|
||||||
|
|
||||||
|
const ChallengeStore = mongoose.model('Challenge', challengeSchema);
|
||||||
|
|
||||||
|
module.exports = ChallengeStore;
|
||||||
22
api/models/schema/challengeSchema.js
Normal file
22
api/models/schema/challengeSchema.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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<MongoUser>} */
|
/** @type {MongooseSchema<MongoUser>} */
|
||||||
const userSchema = mongoose.Schema(
|
const userSchema = mongoose.Schema(
|
||||||
{
|
{
|
||||||
|
|
@ -117,6 +124,10 @@ const userSchema = mongoose.Schema(
|
||||||
unique: true,
|
unique: true,
|
||||||
sparse: true,
|
sparse: true,
|
||||||
},
|
},
|
||||||
|
passkeys: {
|
||||||
|
type: [passkeySchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: [],
|
default: [],
|
||||||
|
|
|
||||||
|
|
@ -21,6 +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 { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {};
|
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {};
|
||||||
|
|
||||||
|
|
@ -77,11 +79,29 @@ const startServer = async () => {
|
||||||
passport.use(ldapLogin);
|
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)) {
|
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
|
||||||
configureSocialLogins(app);
|
configureSocialLogins(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use('/oauth', routes.oauth);
|
app.use('/oauth', routes.oauth);
|
||||||
|
app.use('/webauthn', routes.authWebAuthn);
|
||||||
/* API Endpoints */
|
/* API Endpoints */
|
||||||
app.use('/api/auth', routes.auth);
|
app.use('/api/auth', routes.auth);
|
||||||
app.use('/api/actions', routes.actions);
|
app.use('/api/actions', routes.actions);
|
||||||
|
|
|
||||||
44
api/server/routes/authWebAuthn.js
Normal file
44
api/server/routes/authWebAuthn.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -51,6 +51,7 @@ router.get('/', async function (req, res) {
|
||||||
!!process.env.APPLE_TEAM_ID &&
|
!!process.env.APPLE_TEAM_ID &&
|
||||||
!!process.env.APPLE_KEY_ID &&
|
!!process.env.APPLE_KEY_ID &&
|
||||||
!!process.env.APPLE_PRIVATE_KEY_PATH,
|
!!process.env.APPLE_PRIVATE_KEY_PATH,
|
||||||
|
passkeyLoginEnabled : !!process.env.PASSKEY_ENABLED && !!process.env.RP_ID,
|
||||||
openidLoginEnabled:
|
openidLoginEnabled:
|
||||||
!!process.env.OPENID_CLIENT_ID &&
|
!!process.env.OPENID_CLIENT_ID &&
|
||||||
!!process.env.OPENID_CLIENT_SECRET &&
|
!!process.env.OPENID_CLIENT_SECRET &&
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
const authWebAuthn = require('./authWebAuthn');
|
||||||
const assistants = require('./assistants');
|
const assistants = require('./assistants');
|
||||||
const categories = require('./categories');
|
const categories = require('./categories');
|
||||||
const tokenizer = require('./tokenizer');
|
const tokenizer = require('./tokenizer');
|
||||||
|
|
@ -55,5 +56,6 @@ module.exports = {
|
||||||
assistants,
|
assistants,
|
||||||
categories,
|
categories,
|
||||||
staticRoute,
|
staticRoute,
|
||||||
|
authWebAuthn,
|
||||||
banner,
|
banner,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
221
client/src/components/Auth/PasskeyAuth.tsx
Normal file
221
client/src/components/Auth/PasskeyAuth.tsx
Normal file
|
|
@ -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<PasskeyAuthProps> = ({ 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 (
|
||||||
|
<div className="mt-6">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="passkey-email"
|
||||||
|
autoComplete="email"
|
||||||
|
aria-label={localize('com_auth_email')}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => 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=" "
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? localize('com_auth_loading')
|
||||||
|
: localize(
|
||||||
|
mode === 'login'
|
||||||
|
? 'com_auth_passkey_login'
|
||||||
|
: 'com_auth_passkey_register',
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{onBack && (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-sm font-medium text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{localize(
|
||||||
|
mode === 'login'
|
||||||
|
? 'com_auth_back_to_login'
|
||||||
|
: 'com_auth_back_to_register',
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
@ -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 SocialButton from './SocialButton';
|
||||||
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
import { TStartupConfig } from 'librechat-data-provider';
|
import { TStartupConfig } from 'librechat-data-provider';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
function SocialLoginRender({
|
type SocialLoginRenderProps = {
|
||||||
startupConfig,
|
|
||||||
}: {
|
|
||||||
startupConfig: TStartupConfig | null | undefined;
|
startupConfig: TStartupConfig | null | undefined;
|
||||||
}) {
|
mode: 'login' | 'register';
|
||||||
|
onPasskeyClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SocialLoginRender({ startupConfig, mode, onPasskeyClick }: SocialLoginRenderProps) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
if (!startupConfig) {
|
if (!startupConfig) {
|
||||||
return null;
|
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 = {
|
const providerComponents = {
|
||||||
discord: startupConfig.discordLoginEnabled && (
|
discord: startupConfig.discordLoginEnabled && (
|
||||||
<SocialButton
|
<SocialButton
|
||||||
|
|
@ -99,7 +113,7 @@ function SocialLoginRender({
|
||||||
<>
|
<>
|
||||||
<div className="relative mt-6 flex w-full items-center justify-center border border-t border-gray-300 uppercase dark:border-gray-600">
|
<div className="relative mt-6 flex w-full items-center justify-center border border-t border-gray-300 uppercase dark:border-gray-600">
|
||||||
<div className="absolute bg-white px-3 text-xs text-black dark:bg-gray-900 dark:text-white">
|
<div className="absolute bg-white px-3 text-xs text-black dark:bg-gray-900 dark:text-white">
|
||||||
Or
|
Or
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8" />
|
<div className="mt-8" />
|
||||||
|
|
@ -107,6 +121,26 @@ function SocialLoginRender({
|
||||||
)}
|
)}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{startupConfig.socialLogins?.map((provider) => providerComponents[provider] || null)}
|
{startupConfig.socialLogins?.map((provider) => providerComponents[provider] || null)}
|
||||||
|
{startupConfig.passkeyLoginEnabled && (
|
||||||
|
|
||||||
|
<div className="mt-2 flex gap-x-2">
|
||||||
|
<a
|
||||||
|
aria-label={passkeyLabel}
|
||||||
|
className="flex w-full items-center space-x-3 rounded-2xl border border-border-light bg-surface-primary px-5 py-3 text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
|
||||||
|
data-testid='passkey'
|
||||||
|
href=''
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (onPasskeyClick) {
|
||||||
|
onPasskeyClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PasskeyIcon />
|
||||||
|
<p>{passkeyLabel}</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||||
import DisplayUsernameMessages from './DisplayUsernameMessages';
|
import DisplayUsernameMessages from './DisplayUsernameMessages';
|
||||||
import DeleteAccount from './DeleteAccount';
|
import DeleteAccount from './DeleteAccount';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
|
import PassKeys from './PassKeys';
|
||||||
|
|
||||||
function Account() {
|
function Account() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -15,6 +16,9 @@ function Account() {
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<DisplayUsernameMessages />
|
<DisplayUsernameMessages />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pb-3">
|
||||||
|
<PassKeys />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx
Normal file
71
client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Label className="font-light">{localize('com_nav_passkeys')}</Label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setPasskeyModalOpen(true)}
|
||||||
|
className="ml-4 transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{localize('com_nav_view_passkeys')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Passkey Modal */}
|
||||||
|
<OGDialog open={isPasskeyModalOpen} onOpenChange={setPasskeyModalOpen}>
|
||||||
|
<OGDialogContent className="w-11/12 max-w-lg">
|
||||||
|
<OGDialogHeader>
|
||||||
|
<OGDialogTitle className="text-lg font-medium leading-6">
|
||||||
|
{localize('com_nav_passkeys')}
|
||||||
|
</OGDialogTitle>
|
||||||
|
</OGDialogHeader>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{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}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm break-all">
|
||||||
|
<strong>Public Key:</strong> {Buffer.from(passkey.publicKey).toString('base64')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
<strong>Usage Counter:</strong> {passkey.counter}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
<strong>Transports:</strong> {passkey.transports.length > 0 ? passkey.transports.join(', ') : 'None'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setPasskeyModalOpen(false)}
|
||||||
|
className="transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{localize('com_ui_close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OGDialogContent>
|
||||||
|
</OGDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
client/src/components/svg/PasskeyIcon.tsx
Normal file
12
client/src/components/svg/PasskeyIcon.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function PasskeyIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="passKey" className="h-5 w-5">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M7.494 8.835c-.38-.071-.76-.142-1.122-.295-1.365-.57-2.164-1.63-2.42-3.124-.176-1.022-.096-2.03.318-2.986C4.86 1.07 5.91.337 7.295.085c.827-.147 1.65-.114 2.449.181 1.198.447 2.002 1.303 2.373 2.563.375 1.27.318 2.544-.248 3.747-.59 1.256-1.612 1.931-2.91 2.197-.11.024-.214.043-.323.067H7.494zm7.438 6.363c-1.541-1.265-2.716-2.872-2.716-5.412h-8.25c-1.731 0-3.134 1.422-3.134 3.182v3.975c0 .88.7 1.588 1.565 1.588h10.965c.866 0 1.565-.713 1.565-1.588v-1.74zm8.236-5.455c0 2.15-1.303 3.985-3.13 4.684l1.042 1.87-1.536 2.054 1.536 2.006L18.39 24V10.49c.637 0 1.15-.537 1.15-1.203s-.513-1.203-1.15-1.203V4.746c2.639 0 4.779 2.235 4.779 4.988zm-.014-.014c0 2.178-1.341 4.028-3.205 4.703l1.127 1.87-1.67 2.053 1.67 2.007-2.692 3.61-1.897-2.026v-7.652c-1.688-.765-2.868-2.52-2.868-4.565 0-2.748 2.135-4.974 4.765-4.974S23.15 6.981 23.15 9.73zm-4.765.761c.637 0 1.15-.537 1.15-1.203s-.513-1.203-1.15-1.203c-.637 0-1.151.537-1.151 1.203s.514 1.203 1.15 1.203z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ export { default as OpenIDIcon } from './OpenIDIcon';
|
||||||
export { default as GithubIcon } from './GithubIcon';
|
export { default as GithubIcon } from './GithubIcon';
|
||||||
export { default as DiscordIcon } from './DiscordIcon';
|
export { default as DiscordIcon } from './DiscordIcon';
|
||||||
export { default as AppleIcon } from './AppleIcon';
|
export { default as AppleIcon } from './AppleIcon';
|
||||||
|
export { default as PasskeyIcon } from './PasskeyIcon';
|
||||||
export { default as AnthropicIcon } from './AnthropicIcon';
|
export { default as AnthropicIcon } from './AnthropicIcon';
|
||||||
export { default as SendIcon } from './SendIcon';
|
export { default as SendIcon } from './SendIcon';
|
||||||
export { default as LinkIcon } from './LinkIcon';
|
export { default as LinkIcon } from './LinkIcon';
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,7 @@ export type TStartupConfig = {
|
||||||
googleLoginEnabled: boolean;
|
googleLoginEnabled: boolean;
|
||||||
openidLoginEnabled: boolean;
|
openidLoginEnabled: boolean;
|
||||||
appleLoginEnabled: boolean;
|
appleLoginEnabled: boolean;
|
||||||
|
passkeyLoginEnabled: boolean;
|
||||||
openidLabel: string;
|
openidLabel: string;
|
||||||
openidImageUrl: string;
|
openidImageUrl: string;
|
||||||
/** LDAP Auth Configuration */
|
/** LDAP Auth Configuration */
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,13 @@ export type TError = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TPasskey = {
|
||||||
|
id: string;
|
||||||
|
publicKey: Buffer;
|
||||||
|
counter: number;
|
||||||
|
transports: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type TUser = {
|
export type TUser = {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -108,6 +115,7 @@ export type TUser = {
|
||||||
role: string;
|
role: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
plugins?: string[];
|
plugins?: string[];
|
||||||
|
passkeys?: TPasskey[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue