refactor: rename passkeyChallenge to webauthnChallenge

- Renamed table for consistency with webauthnCredentials
- Created migration script 1.8.1.ts for table rename
- Updated schema definitions in SQLite and PostgreSQL
- Maintains WebAuthn standard naming convention
This commit is contained in:
Adrian Astles 2025-07-03 21:53:07 +08:00
parent baee745d3c
commit db76558944
19 changed files with 1735 additions and 387 deletions

View file

@ -1132,5 +1132,22 @@
"initialSetupTitle": "Initial Server Setup",
"initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.",
"createAdminAccount": "Create Admin Account",
"setupErrorCreateAdmin": "An error occurred while creating the server admin account."
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"passkeyManage": "Manage Passkeys",
"passkeyDescription": "Add or remove passkeys for passwordless authentication",
"passkeyRegister": "Register New Passkey",
"passkeyList": "Your Passkeys",
"passkeyNone": "No passkeys registered yet",
"passkeyNameRequired": "Name is required",
"passkeyRemove": "Remove",
"passkeyLastUsed": "Last used: {date}",
"passkeyNameLabel": "Name",
"passkeyNamePlaceholder": "Enter a name for this passkey",
"passkeyRegisterSuccess": "Passkey registered successfully",
"passkeyRegisterError": "Failed to register passkey",
"passkeyRemoveSuccess": "Passkey removed successfully",
"passkeyRemoveError": "Failed to remove passkey",
"passkeyLoadError": "Failed to load passkeys",
"passkeyLogin": "Login with Passkey",
"passkeyAuthError": "Failed to authenticate with passkey"
}

948
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -52,6 +52,8 @@
"@react-email/components": "0.0.41",
"@react-email/render": "^1.1.2",
"@react-email/tailwind": "1.0.5",
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/server": "^9.0.3",
"@tailwindcss/forms": "^0.5.10",
"@tanstack/react-table": "8.21.3",
"arctic": "^3.7.0",
@ -97,7 +99,7 @@
"react-hook-form": "7.56.4",
"react-icons": "^5.5.0",
"rebuild": "0.1.2",
"semver": "7.7.2",
"semver": "^7.7.2",
"swagger-ui-express": "^5.0.1",
"tailwind-merge": "2.6.0",
"tw-animate-css": "^1.3.3",
@ -106,9 +108,9 @@
"winston": "3.17.0",
"winston-daily-rotate-file": "5.0.0",
"ws": "8.18.2",
"yargs": "18.0.0",
"zod": "3.25.56",
"zod-validation-error": "3.4.1",
"yargs": "18.0.0"
"zod-validation-error": "3.4.1"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.44.1",
@ -119,6 +121,7 @@
"@types/cors": "2.8.19",
"@types/crypto-js": "^4.2.2",
"@types/express": "5.0.0",
"@types/express-session": "^1.18.2",
"@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.9",
@ -126,7 +129,7 @@
"@types/nodemailer": "6.4.17",
"@types/react": "19.1.7",
"@types/react-dom": "19.1.6",
"@types/semver": "7.7.0",
"@types/semver": "^7.7.0",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1",
"@types/yargs": "17.0.33",

View file

@ -491,6 +491,16 @@ export const idpOrg = pgTable("idpOrg", {
orgMapping: varchar("orgMapping")
});
export const webauthnChallenge = pgTable("webauthnChallenge", {
sessionId: varchar("sessionId").primaryKey(),
challenge: varchar("challenge").notNull(),
passkeyName: varchar("passkeyName"),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp
});
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;

View file

@ -13,6 +13,8 @@ bootstrapVolume();
function createDb() {
const sqlite = new Database(location);
sqlite.pragma('foreign_keys = ON');
sqlite.exec('VACUUM;'); // This will initialize the database file with a valid SQLite header
return DrizzleSqlite(sqlite, { schema });
}

View file

@ -1,12 +1,20 @@
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import db from "./driver";
import path from "path";
import { location } from "./driver";
import Database from "better-sqlite3";
import type { Database as BetterSqlite3Database } from "better-sqlite3";
const migrationsFolder = path.join("server/migrations");
const runMigrations = async () => {
console.log("Running migrations...");
try {
// Initialize the database file with a valid SQLite header
const sqlite = new Database(location) as BetterSqlite3Database;
sqlite.pragma('foreign_keys = ON');
// Run the migrations
migrate(db as any, {
migrationsFolder: migrationsFolder,
});

View file

@ -135,6 +135,29 @@ export const users = sqliteTable("user", {
.default(false)
});
export const passkeys = sqliteTable("webauthnCredentials", {
credentialId: text("credentialId").primaryKey(),
userId: text("userId").notNull().references(() => users.userId, {
onDelete: "cascade"
}),
publicKey: text("publicKey").notNull(),
signCount: integer("signCount").notNull(),
transports: text("transports"),
name: text("name"),
lastUsed: text("lastUsed").notNull(),
dateCreated: text("dateCreated").notNull()
});
export const webauthnChallenge = sqliteTable("webauthnChallenge", {
sessionId: text("sessionId").primaryKey(),
challenge: text("challenge").notNull(),
passkeyName: text("passkeyName"),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
expiresAt: integer("expiresAt").notNull() // Unix timestamp
});
export const newts = sqliteTable("newt", {
newtId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(),

View file

@ -35,7 +35,7 @@ declare global {
interface Request {
apiKey?: ApiKey;
user?: User;
session?: Session;
session: Session;
userOrg?: UserOrg;
apiKeyOrg?: ApiKeyOrg;
userOrgRoleId?: number;

View file

@ -12,3 +12,4 @@ export * from "./resetPassword";
export * from "./checkResourceSession";
export * from "./setServerAdmin";
export * from "./initialSetupComplete";
export * from "./passkey";

View file

@ -0,0 +1,606 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { z } from "zod";
import { db } from "@server/db";
import { User, passkeys, users, webauthnChallenge } from "@server/db";
import { eq, and, lt } from "drizzle-orm";
import { response } from "@server/lib";
import logger from "@server/logger";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from "@simplewebauthn/server";
import type {
GenerateRegistrationOptionsOpts,
VerifyRegistrationResponseOpts,
GenerateAuthenticationOptionsOpts,
VerifyAuthenticationResponseOpts,
VerifiedRegistrationResponse,
VerifiedAuthenticationResponse
} from "@simplewebauthn/server";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
// The RP ID is the domain name of your application
const rpID = new URL(config.getRawConfig().app.dashboard_url).hostname;
const rpName = "Pangolin";
const origin = config.getRawConfig().app.dashboard_url;
// Database-based challenge storage (replaces in-memory storage)
// Challenges are stored in the webauthnChallenge table with automatic expiration
// This supports clustered deployments and persists across server restarts
// Clean up expired challenges every 5 minutes
setInterval(async () => {
try {
const now = Date.now();
await db
.delete(webauthnChallenge)
.where(lt(webauthnChallenge.expiresAt, now));
logger.debug("Cleaned up expired passkey challenges");
} catch (error) {
logger.error("Failed to clean up expired passkey challenges", error);
}
}, 5 * 60 * 1000);
// Helper functions for challenge management
async function storeChallenge(sessionId: string, challenge: string, passkeyName?: string, userId?: string) {
const expiresAt = Date.now() + (10 * 60 * 1000); // 10 minutes
// Delete any existing challenge for this session
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
// Insert new challenge
await db.insert(webauthnChallenge).values({
sessionId,
challenge,
passkeyName,
userId,
expiresAt
});
}
async function getChallenge(sessionId: string) {
const [challengeData] = await db
.select()
.from(webauthnChallenge)
.where(eq(webauthnChallenge.sessionId, sessionId))
.limit(1);
if (!challengeData) {
return null;
}
// Check if expired
if (challengeData.expiresAt < Date.now()) {
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
return null;
}
return challengeData;
}
async function clearChallenge(sessionId: string) {
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
}
export const registerPasskeyBody = z.object({
name: z.string().min(1)
}).strict();
export const verifyRegistrationBody = z.object({
credential: z.any()
}).strict();
export const startAuthenticationBody = z.object({
email: z.string().email().optional()
}).strict();
export const verifyAuthenticationBody = z.object({
credential: z.any()
}).strict();
export async function startRegistration(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = registerPasskeyBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { name } = parsedBody.data;
const user = req.user as User;
// Only allow internal users to use passkeys
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Passkeys are only available for internal users"
)
);
}
try {
// Get existing passkeys for user
const existingPasskeys = await db
.select()
.from(passkeys)
.where(eq(passkeys.userId, user.userId));
const excludeCredentials = existingPasskeys.map(key => ({
id: Buffer.from(key.credentialId, 'base64'),
type: 'public-key' as const,
transports: key.transports ? JSON.parse(key.transports) : undefined
}));
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: user.userId,
userName: user.email || user.username,
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
}
};
const registrationOptions = await generateRegistrationOptions(options);
// Store challenge in database
await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId);
return response(res, {
data: registrationOptions,
success: true,
error: false,
message: "Registration options generated",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to generate registration options"
)
);
}
}
export async function verifyRegistration(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = verifyRegistrationBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { credential } = parsedBody.data;
const user = req.user as User;
// Only allow internal users to use passkeys
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Passkeys are only available for internal users"
)
);
}
try {
// Get challenge from database
const challengeData = await getChallenge(req.session.sessionId);
if (!challengeData) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No challenge found in session or challenge expired"
)
);
}
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false
});
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Verification failed"
)
);
}
// Store the passkey in the database
await db.insert(passkeys).values({
credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'),
userId: user.userId,
publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'),
signCount: registrationInfo.counter || 0,
transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null,
name: challengeData.passkeyName,
lastUsed: new Date().toISOString(),
dateCreated: new Date().toISOString()
});
// Clear challenge data
await clearChallenge(req.session.sessionId);
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Passkey registered successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to verify registration"
)
);
}
}
export async function listPasskeys(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const user = req.user as User;
// Only allow internal users to use passkeys
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Passkeys are only available for internal users"
)
);
}
try {
const userPasskeys = await db
.select()
.from(passkeys)
.where(eq(passkeys.userId, user.userId));
return response<typeof userPasskeys>(res, {
data: userPasskeys,
success: true,
error: false,
message: "Passkeys retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to retrieve passkeys"
)
);
}
}
export async function deletePasskey(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const { credentialId: encodedCredentialId } = req.params;
const credentialId = decodeURIComponent(encodedCredentialId);
const user = req.user as User;
// Only allow internal users to use passkeys
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Passkeys are only available for internal users"
)
);
}
try {
await db
.delete(passkeys)
.where(and(
eq(passkeys.credentialId, credentialId),
eq(passkeys.userId, user.userId)
));
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Passkey deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete passkey"
)
);
}
}
export async function startAuthentication(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = startAuthenticationBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { email } = parsedBody.data;
try {
let allowCredentials: Array<{
id: Buffer;
type: 'public-key';
transports?: string[];
}> = [];
let userId;
// If email is provided, get passkeys for that specific user
if (email) {
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user || user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No passkeys available for this user"
)
);
}
userId = user.userId;
const userPasskeys = await db
.select()
.from(passkeys)
.where(eq(passkeys.userId, user.userId));
if (userPasskeys.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No passkeys registered for this user"
)
);
}
allowCredentials = userPasskeys.map(key => ({
id: Buffer.from(key.credentialId, 'base64'),
type: 'public-key' as const,
transports: key.transports ? JSON.parse(key.transports) : undefined
}));
} else {
// If no email provided, allow any passkey (for resident key authentication)
allowCredentials = [];
}
const options: GenerateAuthenticationOptionsOpts = {
rpID,
allowCredentials,
userVerification: 'preferred',
};
const authenticationOptions = await generateAuthenticationOptions(options);
// Generate a temporary session ID for unauthenticated users
const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`;
// Store challenge in database
await storeChallenge(tempSessionId, authenticationOptions.challenge, undefined, userId);
return response(res, {
data: { ...authenticationOptions, tempSessionId },
success: true,
error: false,
message: "Authentication options generated",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to generate authentication options"
)
);
}
}
export async function verifyAuthentication(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = verifyAuthenticationBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { credential } = parsedBody.data;
const tempSessionId = req.headers['x-temp-session-id'] as string;
if (!tempSessionId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Missing temp session ID"
)
);
}
try {
// Get challenge from database
const challengeData = await getChallenge(tempSessionId);
if (!challengeData) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No challenge found or challenge expired"
)
);
}
// Find the passkey in database
const credentialId = Buffer.from(credential.id, 'base64').toString('base64');
const [passkey] = await db
.select()
.from(passkeys)
.where(eq(passkeys.credentialId, credentialId))
.limit(1);
if (!passkey) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Passkey not found"
)
);
}
// Get the user
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, passkey.userId))
.limit(1);
if (!user || user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User not found or not authorized for passkey authentication"
)
);
}
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: Buffer.from(passkey.credentialId, 'base64'),
credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'),
counter: passkey.signCount,
transports: passkey.transports ? JSON.parse(passkey.transports) : undefined
},
requireUserVerification: false
});
const { verified, authenticationInfo } = verification;
if (!verified) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Authentication failed"
)
);
}
// Update sign count
await db
.update(passkeys)
.set({
signCount: authenticationInfo.newCounter,
lastUsed: new Date().toISOString()
})
.where(eq(passkeys.credentialId, credentialId));
// Create session for the user
const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app");
const token = generateSessionToken();
const session = await createSession(token, user.userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(session.expiresAt)
);
res.setHeader("Set-Cookie", cookie);
// Clear challenge data
await clearChallenge(tempSessionId);
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Authentication successful",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to verify authentication"
)
);
}
}

View file

@ -788,3 +788,36 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
authRouter.put("/set-server-admin", auth.setServerAdmin);
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
// Passkey routes
authRouter.post(
"/passkey/register/start",
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Allow 5 passkey registrations per 15 minutes per IP
keyGenerator: (req) => `passkeyRegister:${req.ip}:${req.user?.userId}`,
handler: (req, res, next) => {
const message = `You can only register ${5} passkeys every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
}
}),
verifySessionUserMiddleware,
auth.startRegistration
);
authRouter.post("/passkey/register/verify", verifySessionUserMiddleware, auth.verifyRegistration);
authRouter.post(
"/passkey/authenticate/start",
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
keyGenerator: (req) => `passkeyAuth:${req.ip}`,
handler: (req, res, next) => {
const message = `You can only attempt passkey authentication ${10} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
}
}),
auth.startAuthentication
);
authRouter.post("/passkey/authenticate/verify", auth.verifyAuthentication);
authRouter.get("/passkey/list", verifySessionUserMiddleware, auth.listPasskeys);
authRouter.delete("/passkey/:credentialId", verifySessionUserMiddleware, auth.deletePasskey);

View file

@ -22,6 +22,8 @@ import m18 from "./scriptsSqlite/1.2.0";
import m19 from "./scriptsSqlite/1.3.0";
import m20 from "./scriptsSqlite/1.5.0";
import m21 from "./scriptsSqlite/1.6.0";
import m22 from "./scriptsSqlite/1.7.0";
import m23 from "./scriptsSqlite/1.8.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -43,7 +45,9 @@ const migrations = [
{ version: "1.2.0", run: m18 },
{ version: "1.3.0", run: m19 },
{ version: "1.5.0", run: m20 },
{ version: "1.6.0", run: m21 }
{ version: "1.6.0", run: m21 },
{ version: "1.7.0", run: m22 },
{ version: "1.8.0", run: m23 }
// Add new migrations here as they are created
] as const;
@ -79,17 +83,21 @@ export async function runMigrations() {
try {
const appVersion = APP_VERSION;
if (exists) {
// Check if the database file exists and has tables
const hasTables = await db.select().from(versionMigrations).limit(1).catch(() => false);
if (hasTables) {
await executeScripts();
} else {
console.log("Running migrations...");
console.log("Running initial migrations...");
try {
migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
migrationsFolder: path.join(APP_PATH, "server", "migrations")
});
console.log("Migrations completed successfully.");
console.log("Initial migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
console.error("Error running initial migrations:", error);
throw error;
}
await db

View file

@ -0,0 +1,31 @@
import { db } from "../../db/sqlite";
import { sql } from "drizzle-orm";
const version = "1.4.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
db.transaction((trx) => {
trx.run(sql`CREATE TABLE 'passkey' (
'credentialId' text PRIMARY KEY NOT NULL,
'userId' text NOT NULL,
'publicKey' text NOT NULL,
'signCount' integer NOT NULL,
'transports' text,
'name' text,
'lastUsed' text NOT NULL,
'dateCreated' text NOT NULL,
FOREIGN KEY ('userId') REFERENCES 'user'('id') ON DELETE CASCADE
);`);
});
console.log(`Migrated database schema`);
} catch (e) {
console.log("Unable to migrate database schema");
throw e;
}
console.log(`${version} migration complete`);
}

View file

@ -0,0 +1,39 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.7.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS passkey (
credentialId TEXT PRIMARY KEY,
userId TEXT NOT NULL,
publicKey TEXT NOT NULL,
signCount INTEGER NOT NULL,
transports TEXT,
name TEXT,
lastUsed TEXT NOT NULL,
dateCreated TEXT NOT NULL,
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
);
`);
})(); // executes the transaction immediately
db.pragma("foreign_keys = ON");
console.log(`Created passkey table`);
} catch (e) {
console.error("Unable to create passkey table");
console.error(e);
throw e;
}
console.log(`${version} migration complete`);
}

View file

@ -0,0 +1,38 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.8.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS passkeyChallenge (
sessionId TEXT PRIMARY KEY,
challenge TEXT NOT NULL,
passkeyName TEXT,
userId TEXT,
expiresAt INTEGER NOT NULL,
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_passkeyChallenge_expiresAt ON passkeyChallenge(expiresAt);
`);
})(); // executes the transaction immediately
db.pragma("foreign_keys = ON");
console.log(`Created passkeyChallenge table`);
} catch (e) {
console.error("Unable to create passkeyChallenge table");
console.error(e);
throw e;
}
console.log(`${version} migration complete`);
}

View file

@ -0,0 +1,27 @@
import { db } from "@server/db";
export default async function migrate() {
try {
console.log("Starting table rename migration...");
// Rename the table
await db.run(`
ALTER TABLE passkeyChallenge RENAME TO webauthnChallenge;
`);
console.log("Successfully renamed table");
// Rename the index
await db.run(`
DROP INDEX IF EXISTS idx_passkeyChallenge_expiresAt;
CREATE INDEX IF NOT EXISTS idx_webauthnChallenge_expiresAt ON webauthnChallenge(expiresAt);
`);
console.log("Successfully updated index");
console.log(`Renamed passkeyChallenge table to webauthnChallenge`);
return true;
} catch (error: any) {
console.error("Unable to rename passkeyChallenge table:", error);
console.error("Error details:", error.message);
return false;
}
}

View file

@ -26,7 +26,7 @@ import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { LockIcon } from "lucide-react";
import { LockIcon, FingerprintIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
@ -41,6 +41,7 @@ import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
import { startAuthentication } from "@simplewebauthn/browser";
export type LoginFormIDP = {
idpId: number;
@ -165,6 +166,52 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
}
async function loginWithPasskey() {
try {
setLoading(true);
setError(null);
const email = form.getValues().email;
// Start passkey authentication
const startRes = await api.post("/auth/passkey/authenticate/start", {
email: email || undefined
});
if (!startRes) {
setError(t('passkeyAuthError'));
return;
}
const { tempSessionId, ...options } = startRes.data.data;
// Perform passkey authentication
const credential = await startAuthentication(options);
// Verify authentication
const verifyRes = await api.post(
"/auth/passkey/authenticate/verify",
{ credential },
{
headers: {
'X-Temp-Session-Id': tempSessionId
}
}
);
if (verifyRes) {
if (onLogin) {
await onLogin();
}
}
} catch (e) {
console.error(e);
setError(formatAxiosError(e, t('passkeyAuthError')));
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4">
{!mfaRequested && (
@ -321,6 +368,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
{t('login')}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={loginWithPasskey}
loading={loading}
disabled={loading}
>
<FingerprintIcon className="w-4 h-4 mr-2" />
{t('passkeyLogin')}
</Button>
{hasIdp && (
<>
<div className="relative my-4">

View file

@ -0,0 +1,234 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { startRegistration } from "@simplewebauthn/browser";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
type PasskeyFormProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
type Passkey = {
credentialId: string;
name: string;
dateCreated: string;
lastUsed: string;
};
export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
const [loading, setLoading] = useState(false);
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const { user } = useUserContext();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const registerSchema = z.object({
name: z.string().min(1, { message: t('passkeyNameRequired') })
});
const form = useForm<z.infer<typeof registerSchema>>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: ""
}
});
useEffect(() => {
if (open) {
loadPasskeys();
}
}, [open]);
const loadPasskeys = async () => {
try {
const response = await api.get("/auth/passkey/list");
setPasskeys(response.data.data);
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, t('passkeyLoadError')),
variant: "destructive"
});
}
};
const handleRegisterPasskey = async (values: z.infer<typeof registerSchema>) => {
try {
setLoading(true);
// Start registration
const startRes = await api.post("/auth/passkey/register/start", {
name: values.name
});
const options = startRes.data.data;
// Create passkey
const credential = await startRegistration(options);
// Verify registration
await api.post("/auth/passkey/register/verify", {
credential
});
toast({
title: "Success",
description: t('passkeyRegisterSuccess')
});
// Reload passkeys
await loadPasskeys();
form.reset();
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, t('passkeyRegisterError')),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleDeletePasskey = async (credentialId: string) => {
try {
setLoading(true);
const encodedCredentialId = encodeURIComponent(credentialId);
await api.delete(`/auth/passkey/${encodedCredentialId}`);
toast({
title: "Success",
description: t('passkeyRemoveSuccess')
});
// Reload passkeys
await loadPasskeys();
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, t('passkeyRemoveError')),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('passkeyManage')}</CredenzaTitle>
<CredenzaDescription>
{t('passkeyDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
<div className="space-y-4">
<h3 className="font-semibold">{t('passkeyList')}</h3>
{passkeys.length === 0 ? (
<p className="text-sm text-gray-500">
{t('passkeyNone')}
</p>
) : (
<div className="space-y-2">
{passkeys.map((passkey) => (
<div
key={passkey.credentialId}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="font-medium">{passkey.name}</p>
<p className="text-sm text-gray-500">
{t('passkeyLastUsed', {
date: new Date(passkey.lastUsed).toLocaleDateString()
})}
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeletePasskey(passkey.credentialId)}
disabled={loading}
>
{t('passkeyRemove')}
</Button>
</div>
))}
</div>
)}
</div>
<div className="space-y-4">
<h3 className="font-semibold">{t('passkeyRegister')}</h3>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleRegisterPasskey)}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('passkeyNameLabel')}</FormLabel>
<FormControl>
<Input
placeholder={t('passkeyNamePlaceholder')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={loading}
disabled={loading}
>
{t('passkeyRegister')}
</Button>
</form>
</Form>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View file

@ -21,12 +21,12 @@ import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm";
import PasskeyForm from "./PasskeyForm";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from '@app/components/LocaleSwitcher';
import { useTranslations } from "next-intl";
export default function ProfileIcon() {
const { setTheme, theme } = useTheme();
const { env } = useEnvContext();
@ -40,6 +40,7 @@ export default function ProfileIcon() {
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const [openPasskey, setOpenPasskey] = useState(false);
const t = useTranslations();
@ -73,6 +74,7 @@ export default function ProfileIcon() {
<>
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<PasskeyForm open={openPasskey} setOpen={setOpenPasskey} />
<div className="flex items-center md:gap-2 grow min-w-0 gap-2 md:gap-0">
<span className="truncate max-w-full font-medium min-w-0">
@ -130,6 +132,11 @@ export default function ProfileIcon() {
<span>{t('otpDisable')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setOpenPasskey(true)}
>
<span>{t('passkeyManage')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}