first
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import type { Handler } from 'hono';
|
||||
import type { User } from 'types';
|
||||
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 {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/server';
|
||||
|
||||
export const passkeyRouter = createRouter();
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const RP_NAME = 'Officer';
|
||||
|
||||
function getRpId(origin: string): string {
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return 'officer.dev';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
const origin = ctx.get('origin') as string;
|
||||
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 options = await generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
rpID: rpId,
|
||||
userName: email!,
|
||||
userDisplayName: email!,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existingPasskeys.map((p) => ({
|
||||
id: p.credentialId!,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
|
||||
await storeChallenge(email!, origin, options.challenge);
|
||||
return ctx.json(options);
|
||||
};
|
||||
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
|
||||
|
||||
// Verify and store new passkey registration
|
||||
const passkeyRouterPost: Handler = async (ctx) => {
|
||||
const origin = ctx.get('origin') as string;
|
||||
const rpId = getRpId(origin);
|
||||
const { email } = ctx.get('user') as User;
|
||||
const response = ctx.get('body') as RegistrationResponseJSON;
|
||||
|
||||
const storedChallenge = await getAndDeleteChallenge(email, origin);
|
||||
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: storedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw errors.BAD_CREDENTIALS();
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||
|
||||
const values = {
|
||||
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);
|
||||
|
||||
// Generate authentication options for signing in with passkey
|
||||
const passkeyRouterGet: Handler = async (ctx) => {
|
||||
const { email } = ctx.req.param();
|
||||
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 options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: passkeys.map((p) => ({
|
||||
id: p.credentialId!,
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
await storeChallenge(email!, origin, options.challenge);
|
||||
return ctx.json(options);
|
||||
};
|
||||
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
|
||||
|
||||
// Verify passkey authentication and issue token
|
||||
const passkeyRouterPostVerify: Handler = async (ctx) => {
|
||||
const { email } = ctx.req.param();
|
||||
const origin = ctx.get('origin') as string;
|
||||
const rpId = getRpId(origin);
|
||||
const response = ctx.get('body') as AuthenticationResponseJSON;
|
||||
|
||||
const storedChallenge = await getAndDeleteChallenge(email!, origin);
|
||||
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)),
|
||||
});
|
||||
|
||||
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: storedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
credential: {
|
||||
id: dbPasskey.credentialId!,
|
||||
publicKey: Buffer.from(dbPasskey.publicKey, 'base64'),
|
||||
counter: dbPasskey.counter,
|
||||
},
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
|
||||
const { id, name, role } = dbUser;
|
||||
const passkeys = dbUser.passkeys?.length ?? 0;
|
||||
const token = await sign({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
passkeys,
|
||||
});
|
||||
|
||||
return ctx.json({
|
||||
token,
|
||||
user: {
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
passkeys,
|
||||
},
|
||||
});
|
||||
};
|
||||
passkeyRouter.post('/verify/:email', passkeyRateLimiter, passkeyRouterPostVerify);
|
||||
Reference in New Issue
Block a user