Implement WebAuthn passkey authentication

Add complete WebAuthn support for passkey registration and authentication:
- Install @simplewebauthn/server for WebAuthn utilities
- Create passkey-helpers.ts with base64url/Uint8Array conversion utilities
- Create webauthn.ts with registration/authentication option generation and verification
- Create context.ts with API context types
- Implement all WebAuthn router handlers (createRegistrationOptions, verifyRegistration, createAuthenticationOptions, verifyAuthentication)
- Implement passkey management handlers (listPasskeys, createPasskey, renamePasskey, deletePasskey)
- Add WebAuthn configuration constants and environment variables

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 12:34:26 +08:00
parent a4dff188eb
commit b46146faa5
8 changed files with 709 additions and 24 deletions

View File

@@ -1,5 +1,18 @@
import type {
APIContext,
AuthenticatedContext,
LoginRequestContext,
} from "./context.js";
import { implement } from "@orpc/server";
import { contract } from "@reviq/api-contract";
import {
createAuthenticationOptions as createAuthOptions,
createRegistrationOptions as createRegOptions,
getRPInfo,
getUserPasskeys,
verifyAuthentication as verifyAuth,
verifyRegistration as verifyReg,
} from "./utils/webauthn.js";
const os = implement(contract);
@@ -50,24 +63,56 @@ const logout = os.auth.logout.handler(async () => {
// WebAuthn procedures
const createRegistrationOptions =
os.auth.webauthn.createRegistrationOptions.handler(async () => {
throw new Error("Not implemented");
});
os.auth.webauthn.createRegistrationOptions.handler(
async ({ input, context }) => {
const ctx = context as APIContext;
const { email } = input;
// For signup flow, we don't have a user yet
// The user will be created when signup is called with the passkeyInfo
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
const result = await createRegOptions(ctx.db, rpInfo, { email });
return result;
},
);
const verifyRegistration = os.auth.webauthn.verifyRegistration.handler(
async () => {
throw new Error("Not implemented");
async ({ input, context }) => {
const ctx = context as AuthenticatedContext;
const { challengeId, response } = input;
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
await verifyReg(ctx.db, rpInfo, ctx.user.id, challengeId, response);
},
);
const createAuthenticationOptions =
os.auth.webauthn.createAuthenticationOptions.handler(async () => {
throw new Error("Not implemented");
os.auth.webauthn.createAuthenticationOptions.handler(async ({ context }) => {
const ctx = context as LoginRequestContext;
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
const result = await createAuthOptions(ctx.db, rpInfo, ctx.user.id);
return result;
});
const verifyAuthentication = os.auth.webauthn.verifyAuthentication.handler(
async () => {
throw new Error("Not implemented");
async ({ input, context }) => {
const ctx = context as LoginRequestContext;
const { challengeId, response } = input;
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
const verified = await verifyAuth(
ctx.db,
rpInfo,
ctx.user.id,
challengeId,
response,
);
if (!verified) {
throw new Error("Authentication failed");
}
},
);
@@ -92,21 +137,80 @@ const setPassword = os.me.setPassword.handler(async () => {
throw new Error("Not implemented");
});
const listPasskeys = os.me.listPasskeys.handler(async () => {
throw new Error("Not implemented");
const listPasskeys = os.me.listPasskeys.handler(async ({ context }) => {
const ctx = context as AuthenticatedContext;
const passkeys = await getUserPasskeys(ctx.db, ctx.user.id);
return passkeys.map((p) => ({
id: p.id,
name: p.name,
createdAt: p.createdAt,
lastUsedAt: p.lastUsedAt,
}));
});
const createPasskey = os.me.createPasskey.handler(async () => {
throw new Error("Not implemented");
});
const createPasskey = os.me.createPasskey.handler(
async ({ input, context }) => {
const ctx = context as AuthenticatedContext;
const { name: _name } = input;
const renamePasskey = os.me.renamePasskey.handler(async () => {
throw new Error("Not implemented");
});
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
const result = await createRegOptions(ctx.db, rpInfo, {
id: ctx.user.id,
email: ctx.user.email,
displayName: ctx.user.displayName,
});
const deletePasskey = os.me.deletePasskey.handler(async () => {
throw new Error("Not implemented");
});
return result;
},
);
const renamePasskey = os.me.renamePasskey.handler(
async ({ input, context }) => {
const ctx = context as AuthenticatedContext;
const { passkeyId, name } = input;
await ctx.db
.updateTable("passkeys")
.set({ name })
.where("id", "=", String(passkeyId))
.where("user_id", "=", ctx.user.id)
.execute();
},
);
const deletePasskey = os.me.deletePasskey.handler(
async ({ input, context }) => {
const ctx = context as AuthenticatedContext;
const { passkeyId } = input;
// Check if this is the last passkey and user has no password
const user = await ctx.db
.selectFrom("users")
.select(["password_hash"])
.where("id", "=", ctx.user.id)
.executeTakeFirst();
const passkeyCount = await ctx.db
.selectFrom("passkeys")
.select(ctx.db.fn.countAll().as("count"))
.where("user_id", "=", ctx.user.id)
.executeTakeFirst();
if (!user?.password_hash && Number(passkeyCount?.count ?? 0) <= 1) {
throw new Error(
"Cannot delete the last passkey when you have no password set",
);
}
await ctx.db
.deleteFrom("passkeys")
.where("id", "=", String(passkeyId))
.where("user_id", "=", ctx.user.id)
.execute();
},
);
const listSessions = os.me.listSessions.handler(async () => {
throw new Error("Not implemented");