file based auth

This commit is contained in:
2026-02-23 09:58:08 +00:00
parent 0bea406dcd
commit 2779fe22c6
40 changed files with 456 additions and 1010 deletions
+22 -66
View File
@@ -4,7 +4,15 @@ import { createRouter } from '../../create-router';
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
import { sign } from '../../jwt';
import * as errors from '../../custom-errors';
import { officerdb, eq, and, lt, Passkeys, Users, PasskeyChallenges } from 'officerdb';
import {
getUserByEmail,
getPasskeysByEmailAndOrigin,
getPasskeyByCredentialId,
createPasskey,
updatePasskey,
storeChallenge,
consumeChallenge,
} from 'officerdb';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
@@ -27,45 +35,6 @@ function getRpId(origin: string): string {
}
}
async function storeChallenge(email: string, origin: string, challenge: string) {
await officerdb
.insert(PasskeyChallenges)
.values({ email, origin, challenge })
.onConflictDoUpdate({
target: [PasskeyChallenges.email, PasskeyChallenges.origin],
set: { challenge, createdAt: new Date() },
});
}
async function getAndDeleteChallenge(email: string, origin: string): Promise<string | null> {
const minValidTime = new Date(Date.now() - CHALLENGE_TTL_MS);
// Delete expired challenges for this email/origin
await officerdb
.delete(PasskeyChallenges)
.where(
and(
eq(PasskeyChallenges.email, email),
eq(PasskeyChallenges.origin, origin),
lt(PasskeyChallenges.createdAt, minValidTime),
),
);
// Get and delete the challenge in one operation
const result = await officerdb
.delete(PasskeyChallenges)
.where(and(eq(PasskeyChallenges.email, email), eq(PasskeyChallenges.origin, origin)))
.returning();
const entry = result[0];
if (!entry) return null;
// Double-check TTL (in case of race condition)
if (entry.createdAt < minValidTime) return null;
return entry.challenge;
}
// Generate registration options (challenge) for creating a new passkey
const passkeyRouterPostChallenge: Handler = async (ctx) => {
const { email } = ctx.req.param();
@@ -73,9 +42,7 @@ const passkeyRouterPostChallenge: Handler = async (ctx) => {
const rpId = getRpId(origin);
// Get existing passkeys to exclude them
const existingPasskeys = await officerdb.query.Passkeys.findMany({
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
});
const existingPasskeys = getPasskeysByEmailAndOrigin(email!, origin);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
@@ -104,7 +71,7 @@ const passkeyRouterPost: Handler = async (ctx) => {
const { email } = ctx.get('user') as User;
const response = ctx.get('body') as RegistrationResponseJSON;
const storedChallenge = await getAndDeleteChallenge(email, origin);
const storedChallenge = await consumeChallenge(email, origin, CHALLENGE_TTL_MS);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
const verification = await verifyRegistrationResponse({
@@ -120,15 +87,14 @@ const passkeyRouterPost: Handler = async (ctx) => {
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
const values = {
await createPasskey({
email,
origin,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey).toString('base64'),
counter: credential.counter,
};
});
await officerdb.insert(Passkeys).values(values);
return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp });
};
passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost);
@@ -139,9 +105,7 @@ const passkeyRouterGet: Handler = async (ctx) => {
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const passkeys = await officerdb.query.Passkeys.findMany({
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
});
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
const options = await generateAuthenticationOptions({
rpID: rpId,
@@ -163,13 +127,11 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
const rpId = getRpId(origin);
const response = ctx.get('body') as AuthenticationResponseJSON;
const storedChallenge = await getAndDeleteChallenge(email!, origin);
const storedChallenge = await consumeChallenge(email!, origin, CHALLENGE_TTL_MS);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
// Find the passkey being used
const dbPasskey = await officerdb.query.Passkeys.findFirst({
where: and(eq(Passkeys.email, email!), eq(Passkeys.credentialId, response.id)),
});
const dbPasskey = getPasskeyByCredentialId(email!, response.id);
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
@@ -188,27 +150,21 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
if (!verification.verified) throw errors.BAD_CREDENTIALS();
// Update counter to prevent replay attacks
await officerdb
.update(Passkeys)
.set({ counter: verification.authenticationInfo.newCounter })
.where(eq(Passkeys.id, dbPasskey.id));
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.email, email!),
with: { passkeys: true },
});
await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter });
const dbUser = getUserByEmail(email!);
if (!dbUser) throw errors.UNAUTHORIZED();
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
const { id, name, username, role } = dbUser;
const passkeys = dbUser.passkeys?.length ?? 0;
const token = await sign({
id,
email,
name,
username,
role,
passkeys,
passkeys: passkeys.length,
});
return ctx.json({
@@ -219,7 +175,7 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
name,
username,
role,
passkeys,
passkeys: passkeys.length,
},
});
};