2025-07-03 21:53:07 +08:00
|
|
|
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";
|
2025-07-03 22:57:29 +08:00
|
|
|
import type {
|
|
|
|
AuthenticatorTransport,
|
|
|
|
PublicKeyCredentialDescriptorJSON
|
|
|
|
} from "@simplewebauthn/types";
|
2025-07-03 21:53:07 +08:00
|
|
|
import config from "@server/lib/config";
|
|
|
|
import { UserType } from "@server/types/UserTypes";
|
2025-07-03 22:57:29 +08:00
|
|
|
import { verifyPassword } from "@server/auth/password";
|
|
|
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
2025-07-03 21:53:07 +08:00
|
|
|
|
|
|
|
// 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({
|
2025-07-03 22:57:29 +08:00
|
|
|
name: z.string().min(1),
|
|
|
|
password: z.string().min(1)
|
2025-07-03 21:53:07 +08:00
|
|
|
}).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();
|
|
|
|
|
2025-07-03 22:57:29 +08:00
|
|
|
export const deletePasskeyBody = z.object({
|
|
|
|
password: z.string().min(1)
|
|
|
|
}).strict();
|
|
|
|
|
2025-07-03 21:53:07 +08:00
|
|
|
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()
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-07-03 22:57:29 +08:00
|
|
|
const { name, password } = parsedBody.data;
|
2025-07-03 21:53:07 +08:00
|
|
|
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 {
|
2025-07-03 22:57:29 +08:00
|
|
|
// Verify password
|
|
|
|
const validPassword = await verifyPassword(password, user.passwordHash!);
|
|
|
|
if (!validPassword) {
|
|
|
|
return next(unauthorized());
|
|
|
|
}
|
|
|
|
|
|
|
|
// If user has 2FA enabled, require a code
|
|
|
|
if (user.twoFactorEnabled) {
|
|
|
|
return response<{ codeRequested: boolean }>(res, {
|
|
|
|
data: { codeRequested: true },
|
|
|
|
success: true,
|
|
|
|
error: false,
|
|
|
|
message: "Two-factor authentication required",
|
|
|
|
status: HttpCode.ACCEPTED
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-07-03 21:53:07 +08:00
|
|
|
// Get existing passkeys for user
|
|
|
|
const existingPasskeys = await db
|
|
|
|
.select()
|
|
|
|
.from(passkeys)
|
|
|
|
.where(eq(passkeys.userId, user.userId));
|
|
|
|
|
|
|
|
const excludeCredentials = existingPasskeys.map(key => ({
|
2025-07-03 22:57:29 +08:00
|
|
|
id: Buffer.from(key.credentialId, 'base64').toString('base64url'),
|
2025-07-03 21:53:07 +08:00
|
|
|
type: 'public-key' as const,
|
2025-07-03 22:57:29 +08:00
|
|
|
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined
|
|
|
|
} satisfies PublicKeyCredentialDescriptorJSON));
|
2025-07-03 21:53:07 +08:00
|
|
|
|
|
|
|
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);
|
|
|
|
|
2025-07-03 22:57:29 +08:00
|
|
|
return response<typeof registrationOptions>(res, {
|
2025-07-03 21:53:07 +08:00
|
|
|
data: registrationOptions,
|
|
|
|
success: true,
|
|
|
|
error: false,
|
2025-07-03 22:57:29 +08:00
|
|
|
message: "Registration options generated successfully",
|
2025-07-03 21:53:07 +08:00
|
|
|
status: HttpCode.OK
|
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
logger.error(error);
|
|
|
|
return next(
|
|
|
|
createHttpError(
|
|
|
|
HttpCode.INTERNAL_SERVER_ERROR,
|
2025-07-03 22:57:29 +08:00
|
|
|
"Failed to start registration"
|
2025-07-03 21:53:07 +08:00
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
2025-07-03 22:57:29 +08:00
|
|
|
const parsedBody = deletePasskeyBody.safeParse(req.body);
|
|
|
|
|
|
|
|
if (!parsedBody.success) {
|
|
|
|
return next(
|
|
|
|
createHttpError(
|
|
|
|
HttpCode.BAD_REQUEST,
|
|
|
|
fromError(parsedBody.error).toString()
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const { password } = parsedBody.data;
|
|
|
|
|
2025-07-03 21:53:07 +08:00
|
|
|
// 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 {
|
2025-07-03 22:57:29 +08:00
|
|
|
// Verify password
|
|
|
|
const validPassword = await verifyPassword(password, user.passwordHash!);
|
|
|
|
if (!validPassword) {
|
|
|
|
return next(unauthorized());
|
|
|
|
}
|
|
|
|
|
|
|
|
// If user has 2FA enabled, require a code
|
|
|
|
if (user.twoFactorEnabled) {
|
|
|
|
return response<{ codeRequested: boolean }>(res, {
|
|
|
|
data: { codeRequested: true },
|
|
|
|
success: true,
|
|
|
|
error: false,
|
|
|
|
message: "Two-factor authentication required",
|
|
|
|
status: HttpCode.ACCEPTED
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-07-03 21:53:07 +08:00
|
|
|
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,
|
2025-07-03 22:57:29 +08:00
|
|
|
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined
|
2025-07-03 21:53:07 +08:00
|
|
|
}));
|
|
|
|
} 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,
|
2025-07-03 22:57:29 +08:00
|
|
|
transports: passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransport[] : undefined
|
2025-07-03 21:53:07 +08:00
|
|
|
},
|
|
|
|
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"
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|