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

@@ -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;
}
}