Compare commits
15 Commits
dev-exampl
...
feat/webau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4914ef5226 | ||
|
|
b4b574e328 | ||
|
|
8173f5fca1 | ||
|
|
6496c9aeda | ||
|
|
dfcbc23b8b | ||
|
|
47a5b0a4d6 | ||
|
|
e9e2917042 | ||
|
|
a0c4ddaf9e | ||
|
|
05a4f6cc45 | ||
|
|
3a60fa1966 | ||
|
|
8ea085ee25 | ||
|
|
1e1b865f4f | ||
|
|
1ab5bc425d | ||
|
|
091d4f3192 | ||
|
|
1cb1c9196d |
@@ -414,6 +414,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=
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -100,10 +100,13 @@ auth.json
|
||||
/images
|
||||
|
||||
!client/src/components/Nav/SettingsTabs/Data/
|
||||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
# User uploads
|
||||
uploads/
|
||||
|
||||
# owner
|
||||
release/
|
||||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
# 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 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 };
|
||||
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;
|
||||
@@ -45,6 +45,13 @@ const backupCodeSchema = mongoose.Schema({
|
||||
usedAt: { type: Date, default: null },
|
||||
});
|
||||
|
||||
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>} */
|
||||
const userSchema = mongoose.Schema(
|
||||
{
|
||||
@@ -123,6 +130,10 @@ const userSchema = mongoose.Schema(
|
||||
unique: true,
|
||||
sparse: true,
|
||||
},
|
||||
passkeys: {
|
||||
type: [passkeySchema],
|
||||
default: [],
|
||||
},
|
||||
plugins: {
|
||||
type: Array,
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-ldapauth": "^3.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-simple-webauthn2": "^3.2.0",
|
||||
"sharp": "^0.32.6",
|
||||
"tiktoken": "^1.0.15",
|
||||
"traverse": "^0.6.7",
|
||||
|
||||
@@ -21,6 +21,8 @@ const AppService = require('./services/AppService');
|
||||
const staticCache = require('./utils/staticCache');
|
||||
const noIndex = require('./middleware/noIndex');
|
||||
const routes = require('./routes');
|
||||
const { mongoUserStore, mongoChallengeStore } = require('~/cache');
|
||||
const { WebAuthnStrategy } = require('passport-simple-webauthn2');
|
||||
|
||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
||||
|
||||
@@ -78,11 +80,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);
|
||||
|
||||
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_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 &&
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import SocialLoginRender from './SocialLoginRender';
|
||||
import { ThemeSelector } from '~/components/ui';
|
||||
import { Banner } from '../Banners';
|
||||
import Footer from './Footer';
|
||||
import { useState } from 'react';
|
||||
import PasskeyAuth from '~/components/Auth/PasskeyAuth';
|
||||
|
||||
const ErrorRender = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="mt-16 flex justify-center">
|
||||
@@ -57,6 +59,12 @@ function AuthLayout({
|
||||
return null;
|
||||
};
|
||||
|
||||
// Determine the mode from the URL: if the pathname contains "register" then mode is "register", else "login"
|
||||
const mode = pathname.includes('register') ? 'register' : 'login';
|
||||
|
||||
// Local state to toggle between the default form (children) and the passkey view.
|
||||
const [showPasskey, setShowPasskey] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col bg-white dark:bg-gray-900">
|
||||
<Banner />
|
||||
@@ -84,10 +92,19 @@ function AuthLayout({
|
||||
{header}
|
||||
</h1>
|
||||
)}
|
||||
{children}
|
||||
{!pathname.includes('2fa') &&
|
||||
(pathname.includes('login') || pathname.includes('register')) && (
|
||||
<SocialLoginRender startupConfig={startupConfig} />
|
||||
{showPasskey ? (
|
||||
<PasskeyAuth mode={mode} onBack={() => setShowPasskey(false)} />
|
||||
) : (
|
||||
<>
|
||||
{children}
|
||||
{!pathname.includes('2fa') && (pathname.includes('login') || pathname.includes('register')) && (
|
||||
<SocialLoginRender
|
||||
startupConfig={startupConfig}
|
||||
mode={mode}
|
||||
onPasskeyClick={() => setShowPasskey(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
283
client/src/components/Auth/PasskeyAuth.tsx
Normal file
283
client/src/components/Auth/PasskeyAuth.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TranslationKeys, useLocalize } from '~/hooks';
|
||||
|
||||
type PasskeyAuthProps = {
|
||||
mode: 'login' | 'register';
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
|
||||
const localize = useLocalize();
|
||||
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(
|
||||
`/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 || localize('com_auth_passkey_error')
|
||||
);
|
||||
}
|
||||
let options = await challengeResponse.json();
|
||||
options = processLoginOptions(options);
|
||||
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey: options,
|
||||
})) as PublicKeyCredential;
|
||||
if (!credential) {
|
||||
throw new Error(localize('com_auth_passkey_no_credentials'));
|
||||
}
|
||||
|
||||
const authenticationResponse = getAuthenticationResponse(credential);
|
||||
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) {
|
||||
// alert(localize('com_auth_passkey_login_success'));
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
throw new Error(
|
||||
result.error || localize('com_auth_passkey_error')
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alertError('com_auth_passkey_failed', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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(
|
||||
`/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 || localize('com_auth_passkey_error')
|
||||
);
|
||||
}
|
||||
let options = await challengeResponse.json();
|
||||
options = processRegistrationOptions(options);
|
||||
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: options,
|
||||
})) as PublicKeyCredential;
|
||||
if (!credential) {
|
||||
throw new Error(localize('com_auth_passkey_create_error'));
|
||||
}
|
||||
|
||||
const registrationResponse = getRegistrationResponse(credential);
|
||||
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) {
|
||||
// alert(localize('com_auth_passkey_register_success'));
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
throw new Error(
|
||||
result.error || localize('com_auth_passkey_error')
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alertError('com_auth_passkey_registration_failed', error);
|
||||
} 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_success'
|
||||
: 'com_auth_passkey_register_success'
|
||||
)}
|
||||
</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 { 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 && (
|
||||
<SocialButton
|
||||
@@ -107,6 +121,21 @@ function SocialLoginRender({
|
||||
)}
|
||||
<div className="mt-2">
|
||||
{startupConfig.socialLogins?.map((provider) => providerComponents[provider] || null)}
|
||||
{startupConfig.passkeyLoginEnabled && (
|
||||
|
||||
<div className="mt-2 flex gap-x-2">
|
||||
<button
|
||||
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"
|
||||
type="button"
|
||||
onClick={onPasskeyClick}
|
||||
>
|
||||
<PasskeyIcon />
|
||||
<p>{passkeyLabel}</p>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import DisplayUsernameMessages from './DisplayUsernameMessages';
|
||||
import DeleteAccount from './DeleteAccount';
|
||||
import Avatar from './Avatar';
|
||||
import PassKeys from './PassKeys';
|
||||
import EnableTwoFactorItem from './TwoFactorAuthentication';
|
||||
import BackupCodesItem from './BackupCodesItem';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
@@ -32,6 +33,9 @@ function Account() {
|
||||
<div className="pb-3">
|
||||
<DeleteAccount />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<PassKeys />
|
||||
</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>{localize('com_nav_settings_passkey_label_id')}</strong> {passkey.id}
|
||||
</p>
|
||||
<p className="text-sm break-all">
|
||||
<strong>{localize('com_nav_settings_passkey_label_public_key')}</strong> {Buffer.from(passkey.publicKey).toString('base64')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<strong>{localize('com_nav_settings_passkey_label_usage_counter')}</strong> {passkey.counter}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<strong>{localize('com_nav_settings_passkey_label_transports')}</strong> {passkey.transports.length > 0 ? passkey.transports.join(', ') : localize('com_nav_settings_passkey_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 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';
|
||||
|
||||
@@ -828,5 +828,31 @@
|
||||
"com_ui_yes": "Yes",
|
||||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "You",
|
||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
|
||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
|
||||
"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_auth_passkey_login": "Login with Passkey",
|
||||
"com_auth_passkey_register": "Register with Passkey",
|
||||
"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",
|
||||
"com_ui_no_valid_items": "something needs to go here. was empty",
|
||||
"chat_direction_left_to_right": "something needs to go here. was empty",
|
||||
"chat_direction_right_to_left": "something needs to go here. was empty"
|
||||
}
|
||||
292
package-lock.json
generated
292
package-lock.json
generated
@@ -113,6 +113,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-ldapauth": "^3.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-simple-webauthn2": "^3.2.0",
|
||||
"sharp": "^0.32.6",
|
||||
"tiktoken": "^1.0.15",
|
||||
"traverse": "^0.6.7",
|
||||
@@ -1705,13 +1706,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"client/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"client/node_modules/vite": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz",
|
||||
@@ -9764,6 +9758,12 @@
|
||||
"react-dom": "^18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -11488,6 +11488,12 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
|
||||
@@ -12448,6 +12454,64 @@
|
||||
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
|
||||
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.15.tgz",
|
||||
"integrity": "sha512-8U2TIj59cRlSXTX2d0mzUKP7whfWGFMzTeC3qPgAbccXFrPNZLaDhpNEdG5U2QZ/tBv/IHlCJ8s+KYXpJeop6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.15",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.15.tgz",
|
||||
"integrity": "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.15",
|
||||
"@peculiar/asn1-x509": "^2.3.15",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.15.tgz",
|
||||
"integrity": "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.15",
|
||||
"@peculiar/asn1-x509": "^2.3.15",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.15.tgz",
|
||||
"integrity": "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1js": "^3.0.5",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.15.tgz",
|
||||
"integrity": "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.15",
|
||||
"asn1js": "^3.0.5",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -14424,6 +14488,71 @@
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz",
|
||||
"integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
"yallist": "4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@redis/graph": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/json": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/search": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/time-series": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.0.tgz",
|
||||
@@ -14863,6 +14992,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "13.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.1.1.tgz",
|
||||
"integrity": "sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.3.10",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-rsa": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.8",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||
@@ -17770,6 +17917,20 @@
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
|
||||
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
|
||||
"integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.2",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assert": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
|
||||
@@ -22383,6 +22544,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/generic-pool": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -26647,9 +26817,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/logform": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz",
|
||||
"integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
||||
"integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@colors/colors": "1.6.0",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
@@ -29577,6 +29748,43 @@
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-simple-webauthn2/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"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",
|
||||
@@ -31407,6 +31615,24 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
|
||||
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
@@ -32167,6 +32393,23 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz",
|
||||
"integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@redis/bloom": "1.2.0",
|
||||
"@redis/client": "1.6.0",
|
||||
"@redis/graph": "1.1.1",
|
||||
"@redis/json": "1.0.7",
|
||||
"@redis/search": "1.2.0",
|
||||
"@redis/time-series": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
@@ -35204,9 +35447,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
|
||||
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tty-browserify": {
|
||||
"version": "0.0.1",
|
||||
@@ -36575,21 +36819,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/winston": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.11.0.tgz",
|
||||
"integrity": "sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==",
|
||||
"version": "3.17.0",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
|
||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
"async": "^3.2.3",
|
||||
"is-stream": "^2.0.0",
|
||||
"logform": "^2.4.0",
|
||||
"logform": "^2.7.0",
|
||||
"one-time": "^1.0.0",
|
||||
"readable-stream": "^3.4.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"stack-trace": "0.0.x",
|
||||
"triple-beam": "^1.3.0",
|
||||
"winston-transport": "^4.5.0"
|
||||
"winston-transport": "^4.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
@@ -36613,12 +36858,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/winston-transport": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.7.0.tgz",
|
||||
"integrity": "sha512-ajBj65K5I7denzer2IYW6+2bNIVqLGDHqDw3Ow8Ohh+vdW+rv4MZ6eiDvHoKhfJFZ2auyN8byXieDDJ96ViONg==",
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
|
||||
"integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"logform": "^2.3.2",
|
||||
"readable-stream": "^3.6.0",
|
||||
"logform": "^2.7.0",
|
||||
"readable-stream": "^3.6.2",
|
||||
"triple-beam": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -482,6 +482,7 @@ export type TStartupConfig = {
|
||||
googleLoginEnabled: boolean;
|
||||
openidLoginEnabled: boolean;
|
||||
appleLoginEnabled: boolean;
|
||||
passkeyLoginEnabled: boolean;
|
||||
openidLabel: string;
|
||||
openidImageUrl: string;
|
||||
/** LDAP Auth Configuration */
|
||||
|
||||
@@ -100,7 +100,14 @@ export type TError = {
|
||||
};
|
||||
};
|
||||
|
||||
export type TBackupCode = {
|
||||
export type TPasskey = {
|
||||
id: string;
|
||||
publicKey: Buffer;
|
||||
counter: number;
|
||||
transports: string[];
|
||||
};
|
||||
|
||||
export type TBackupCode = {
|
||||
codeHash: string;
|
||||
used: boolean;
|
||||
usedAt: Date | null;
|
||||
@@ -115,6 +122,7 @@ export type TUser = {
|
||||
role: string;
|
||||
provider: string;
|
||||
plugins?: string[];
|
||||
passkeys?: TPasskey[];
|
||||
backupCodes?: TBackupCode[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user