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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user