feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { credentials } from "./tables.js";
import { createCredentialRepository } from "./repository.js";
import { createPasskeyService } from "./passkeys.js";
import { PUBLIC_PATHS, registerPasskeyRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(credentials);
const repository = createCredentialRepository(ctx.db, table);
const passkeys = createPasskeyService(ctx, repository);
// No describe: a passkey only helps a user who already registered one, so
// it never counts for the password lockout guard.
ctx.auth.registerLoginMethod({
id: "passkey",
labelKey: "signIn",
icon: "fingerprint",
kind: "form",
verify: (request) => passkeys.verifyLogin(request.body ?? {}),
});
registerPasskeyRoutes(
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
ctx,
repository,
passkeys,
);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+298
View File
@@ -0,0 +1,298 @@
import { randomUUID } from "node:crypto";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
Base64URLString,
RegistrationResponseJSON,
WebAuthnCredential,
} from "@simplewebauthn/server";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import {
LoginMethodError,
type PluginContext,
type PluginVerifiedIdentity,
} from "@termix/plugin-sdk/backend";
import type { CredentialRecord, CredentialRepository } from "./repository.js";
export type UserVerification = "discouraged" | "preferred" | "required";
interface ChallengeRecord {
challenge: string;
userId?: string;
rpID: string;
origin: string;
userVerification: UserVerification;
createdAt: number;
}
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export function normalizeUserVerification(value: unknown): UserVerification {
return value === "discouraged" || value === "required" ? value : "preferred";
}
function header(
headers: Record<string, unknown>,
name: string,
): string | undefined {
const value = headers[name];
if (Array.isArray(value)) return value[0];
return typeof value === "string" ? value : undefined;
}
/** The browser's origin, or the one a proxy reports. */
export function requestOrigin(headers: Record<string, unknown>): string {
const origin = header(headers, "origin");
if (origin) return origin;
const proto = header(headers, "x-forwarded-proto") || "http";
const host =
header(headers, "x-forwarded-host") ||
header(headers, "host") ||
"localhost";
return `${proto.split(",")[0]}://${host.split(",")[0]}`;
}
export function parseTransports(
value: string | null,
): AuthenticatorTransportFuture[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toCredential(record: CredentialRecord): WebAuthnCredential {
return {
id: record.credentialId as Base64URLString,
publicKey: Uint8Array.from(
Buffer.from(record.publicKey, "base64url"),
) as WebAuthnCredential["publicKey"],
counter: record.counter,
transports: parseTransports(record.transports),
};
}
export type PasskeyService = ReturnType<typeof createPasskeyService>;
/**
* Registration and sign-in ceremonies. Challenges live in memory for five
* minutes and are made in activate, so a disabled plugin forgets them.
*/
export function createPasskeyService(
ctx: PluginContext,
repository: CredentialRepository,
) {
const registrations = new Map<string, ChallengeRecord>();
const authentications = new Map<string, ChallengeRecord>();
const prune = (map: Map<string, ChallengeRecord>) => {
const now = Date.now();
for (const [id, record] of map) {
if (now - record.createdAt > CHALLENGE_TTL_MS) map.delete(id);
}
};
const put = (
map: Map<string, ChallengeRecord>,
record: Omit<ChallengeRecord, "createdAt">,
) => {
prune(map);
const id = randomUUID();
map.set(id, { ...record, createdAt: Date.now() });
return id;
};
const take = (map: Map<string, ChallengeRecord>, id: unknown) => {
if (typeof id !== "string") return null;
prune(map);
const record = map.get(id) ?? null;
map.delete(id);
return record;
};
async function rpName(): Promise<string> {
try {
return (await ctx.settings.readCore("app_name")) || "Termix";
} catch {
return "Termix";
}
}
return {
async registrationOptions(input: {
userId: string;
username: string;
origin: string;
userVerification: UserVerification;
}) {
const rpID = new URL(input.origin).hostname;
const existing = await repository.listByUserId(input.userId);
const options = await generateRegistrationOptions({
rpName: await rpName(),
rpID,
userID: Buffer.from(input.userId, "utf8"),
userName: input.username,
userDisplayName: input.username,
attestationType: "none",
excludeCredentials: existing.map((credential) => ({
id: credential.credentialId as Base64URLString,
transports: parseTransports(credential.transports),
})),
authenticatorSelection: {
residentKey: "required",
userVerification: input.userVerification,
},
});
const challengeId = put(registrations, {
challenge: options.challenge,
userId: input.userId,
rpID,
origin: input.origin,
userVerification: input.userVerification,
});
return { options, challengeId };
},
/** Stores the passkey. False when the challenge or response is bad. */
async register(input: {
userId: string;
challengeId: unknown;
response: RegistrationResponseJSON | undefined;
name: unknown;
}): Promise<boolean | "expired"> {
const challenge = take(registrations, input.challengeId);
if (!challenge || challenge.userId !== input.userId) return "expired";
if (!input.response) return false;
const verification = await verifyRegistrationResponse({
response: input.response,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rpID,
requireUserVerification: challenge.userVerification === "required",
});
if (!verification.verified) return false;
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
const name =
typeof input.name === "string" && input.name.trim()
? input.name.trim().slice(0, 80)
: "Passkey";
await repository.create({
id: randomUUID(),
userId: input.userId,
name,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey).toString("base64url"),
counter: credential.counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: JSON.stringify(input.response.response?.transports ?? []),
userVerification: challenge.userVerification,
});
return true;
},
async authenticationOptions(input: {
userId?: string;
origin: string;
userVerification: UserVerification;
}) {
const rpID = new URL(input.origin).hostname;
let allowCredentials:
| { id: Base64URLString; transports?: AuthenticatorTransportFuture[] }[]
| undefined;
if (input.userId) {
const credentials = await repository.listByUserId(input.userId);
if (credentials.length === 0) return null;
allowCredentials = credentials.map((credential) => ({
id: credential.credentialId as Base64URLString,
transports: parseTransports(credential.transports),
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: input.userVerification,
});
const challengeId = put(authentications, {
challenge: options.challenge,
userId: input.userId,
rpID,
origin: input.origin,
userVerification: input.userVerification,
});
return { options, challengeId };
},
/**
* Checks an assertion and says who signed in. A passkey that verified
* the user (PIN or biometric) counts as the second factor too.
*/
async verifyLogin(
body: Record<string, unknown>,
): Promise<PluginVerifiedIdentity> {
const challenge = take(authentications, body.challengeId);
if (!challenge) {
throw new LoginMethodError("Authentication challenge expired", 400);
}
const response = body.response as AuthenticationResponseJSON | undefined;
if (!response?.id) {
throw new LoginMethodError("Invalid passkey response", 400);
}
const credential = await repository.findByCredentialId(response.id);
if (
!credential ||
(challenge.userId && challenge.userId !== credential.userId)
) {
throw new LoginMethodError("Passkey not recognized", 401);
}
let verification: Awaited<
ReturnType<typeof verifyAuthenticationResponse>
>;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rpID,
credential: toCredential(credential),
requireUserVerification: challenge.userVerification === "required",
advancedFIDOConfig: { userVerification: challenge.userVerification },
});
} catch (error) {
ctx.log.warn(
`Passkey authentication failed for ${credential.userId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw new LoginMethodError("Passkey authentication failed", 401);
}
if (!verification.verified) {
throw new LoginMethodError("Passkey authentication failed", 401);
}
await repository.updateAuthState(credential.id, {
counter: verification.authenticationInfo.newCounter,
backedUp: verification.authenticationInfo.credentialBackedUp,
deviceType: verification.authenticationInfo.credentialDeviceType,
lastUsedAt: new Date().toISOString(),
});
return {
kind: "user",
userId: credential.userId,
mfaSatisfied: verification.authenticationInfo.userVerified === true,
rememberMe: !!body.rememberMe,
};
},
};
}
@@ -0,0 +1,83 @@
import { and, eq } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export interface CredentialRecord {
id: string;
userId: string;
name: string;
credentialId: string;
publicKey: string;
counter: number;
deviceType: string | null;
backedUp: boolean;
transports: string | null;
userVerification: string;
createdAt: string;
lastUsedAt: string | null;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the table back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type CredentialRepository = ReturnType<
typeof createCredentialRepository
>;
export function createCredentialRepository(db: PluginDatabase, table: Table) {
const client = () => db.client<Drizzle>();
return {
async listByUserId(userId: string): Promise<CredentialRecord[]> {
const drizzle = await client();
return drizzle.select().from(table).where(eq(table.userId, userId));
},
async findByCredentialId(
credentialId: string,
): Promise<CredentialRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(eq(table.credentialId, credentialId))
.limit(1);
return (rows[0] as CredentialRecord) ?? null;
},
async create(
input: Omit<CredentialRecord, "lastUsedAt" | "createdAt">,
): Promise<void> {
const drizzle = await client();
await drizzle
.insert(table)
.values({ ...input, createdAt: new Date().toISOString() });
await db.persist();
},
async updateAuthState(
id: string,
state: {
counter: number;
backedUp: boolean;
deviceType: string;
lastUsedAt: string;
},
): Promise<void> {
const drizzle = await client();
await drizzle.update(table).set(state).where(eq(table.id, id));
await db.persist();
},
async deleteForUser(userId: string, id: string): Promise<void> {
const drizzle = await client();
await drizzle
.delete(table)
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
},
};
}
+193
View File
@@ -0,0 +1,193 @@
import type { Request, Response, Router } from "express";
import { eq } from "drizzle-orm";
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
normalizeUserVerification,
parseTransports,
requestOrigin,
type PasskeyService,
} from "./passkeys.js";
import type { CredentialRepository } from "./repository.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export const PUBLIC_PATHS = ["/authenticate/options"];
export function registerPasskeyRoutes(
router: Router,
ctx: PluginContext,
repository: CredentialRepository,
passkeys: PasskeyService,
): void {
async function findUser(
column: "id" | "username",
value: string,
): Promise<{ id: string; username: string } | null> {
const { users } = await ctx.db.refs<{ users: Table }>();
const drizzle = await ctx.db.client<Drizzle>();
const rows = await drizzle
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users[column], value))
.limit(1);
return rows[0] ?? null;
}
/**
* @openapi
* /plugin-api/webauthn/credentials:
* get:
* summary: List passkeys
* description: Lists the caller's registered passkeys.
* tags:
* - Passkeys
* responses:
* 200:
* description: List of passkeys.
*/
router.get("/credentials", async (_req: Request, res: Response) => {
const credentials = await repository.listByUserId(ctx.currentActor()!);
res.json({
credentials: credentials.map((credential) => ({
id: credential.id,
name: credential.name,
deviceType: credential.deviceType,
backedUp: !!credential.backedUp,
transports: parseTransports(credential.transports),
userVerification: credential.userVerification,
createdAt: credential.createdAt,
lastUsedAt: credential.lastUsedAt,
})),
});
});
/**
* @openapi
* /plugin-api/webauthn/register/options:
* post:
* summary: Start passkey registration
* tags:
* - Passkeys
* responses:
* 200:
* description: Registration options and challenge id.
* 404:
* description: User not found.
*/
router.post("/register/options", async (req: Request, res: Response) => {
const user = await findUser("id", ctx.currentActor()!);
if (!user) return res.status(404).json({ error: "User not found" });
res.json(
await passkeys.registrationOptions({
userId: user.id,
username: user.username,
origin: requestOrigin(req.headers),
userVerification: normalizeUserVerification(req.body?.userVerification),
}),
);
});
/**
* @openapi
* /plugin-api/webauthn/register/verify:
* post:
* summary: Finish passkey registration
* tags:
* - Passkeys
* responses:
* 200:
* description: Passkey registered.
* 400:
* description: Registration failed or challenge expired.
*/
router.post("/register/verify", async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
const result = await passkeys.register({
userId,
challengeId: req.body?.challengeId,
response: req.body?.response as RegistrationResponseJSON | undefined,
name: req.body?.name,
});
if (result === "expired") {
return res
.status(400)
.json({ error: "Registration challenge expired" });
}
if (!result) {
return res.status(400).json({ error: "Passkey registration failed" });
}
res.json({ success: true });
} catch (error) {
ctx.log.warn(
`Passkey registration failed for ${userId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
res.status(400).json({ error: "Passkey registration failed" });
}
});
/**
* @openapi
* /plugin-api/webauthn/authenticate/options:
* post:
* summary: Start passkey login
* description: Public. Authentication options, optionally scoped to a username. The assertion is then sent to /users/auth/passkey/verify.
* tags:
* - Passkeys
* responses:
* 200:
* description: Authentication options and challenge id.
* 404:
* description: No passkeys found for the user.
*/
router.post("/authenticate/options", async (req: Request, res: Response) => {
const username =
typeof req.body?.username === "string" ? req.body.username.trim() : "";
let userId: string | undefined;
if (username) {
const user = await findUser("username", username);
if (!user) return res.status(404).json({ error: "No passkeys found" });
userId = user.id;
}
const result = await passkeys.authenticationOptions({
userId,
origin: requestOrigin(req.headers),
userVerification: normalizeUserVerification(req.body?.userVerification),
});
if (!result) return res.status(404).json({ error: "No passkeys found" });
res.json(result);
});
/**
* @openapi
* /plugin-api/webauthn/credentials/{credentialId}:
* delete:
* summary: Delete a passkey
* tags:
* - Passkeys
* parameters:
* - in: path
* name: credentialId
* required: true
* schema: { type: string }
* responses:
* 200:
* description: Passkey deleted.
*/
router.delete(
"/credentials/:credentialId",
async (req: Request, res: Response) => {
await repository.deleteForUser(
ctx.currentActor()!,
String(req.params.credentialId),
);
res.json({ success: true });
},
);
}
+34
View File
@@ -0,0 +1,34 @@
import {
adoptLegacyTable,
boolean,
defineTable,
integer,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/**
* Adopted from core's webauthn_credentials, so the column names are the
* legacy ones and the rename carries every passkey across.
*/
export const credentials = adoptLegacyTable(
"webauthn_credentials",
defineTable("credentials", {
id: varchar().primaryKey(),
userId: refUser(),
name: text().notNull(),
credentialId: varchar().notNull().unique(),
publicKey: text().notNull(),
counter: integer().notNull().default(0),
deviceType: text(),
backedUp: boolean().notNull().default(false),
transports: text(),
userVerification: text().notNull().default("preferred"),
createdAt: timestamp().notNull().defaultNow(),
lastUsedAt: timestamp(),
}),
);
export const tables = [credentials];
@@ -0,0 +1,137 @@
import { useEffect, useMemo, useState } from "react";
import { KeyRound, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button, Input, Select2 } from "@termix/plugin-sdk/ui";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import {
createWebAuthnApi,
type WebAuthnCredentialSummary,
type WebAuthnUserVerification,
} from "./webauthn-api";
function apiErrorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
return typeof data?.error === "string" && data.error ? data.error : fallback;
}
/** Settings > Security: register and remove passkeys. */
export function PasskeyEnrollment() {
const { t } = useTranslation();
const pluginApi = usePluginApi();
const api = useMemo(() => createWebAuthnApi(pluginApi), [pluginApi]);
const [passkeys, setPasskeys] = useState<WebAuthnCredentialSummary[]>([]);
const [loading, setLoading] = useState(false);
const [name, setName] = useState("");
const [userVerification, setUserVerification] =
useState<WebAuthnUserVerification>("preferred");
useEffect(() => {
api
.list()
.then(setPasskeys)
.catch(() => {});
}, [api]);
async function add() {
setLoading(true);
try {
await api.register(name || t("defaultName"), userVerification);
setPasskeys(await api.list());
setName("");
toast.success(t("added"));
} catch (e) {
toast.error(apiErrorMessage(e, t("addFailed")));
} finally {
setLoading(false);
}
}
async function remove(id: string) {
setLoading(true);
try {
await api.remove(id);
setPasskeys((prev) => prev.filter((item) => item.id !== id));
toast.success(t("deleted"));
} catch (e) {
toast.error(apiErrorMessage(e, t("deleteFailed")));
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-3">
<span className="text-[10px] text-muted-foreground">
{t("description")}
</span>
<div className="grid grid-cols-[1fr_auto] gap-2">
<Input
placeholder={t("namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="h-8 text-xs"
disabled={loading}
/>
<Select2
value={userVerification}
onChange={(e) =>
setUserVerification(e.target.value as WebAuthnUserVerification)
}
className="h-8 w-28 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
disabled={loading}
>
<option value="preferred">{t("uv.preferred")}</option>
<option value="required">{t("uv.required")}</option>
<option value="discouraged">{t("uv.discouraged")}</option>
</Select2>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={add}
disabled={loading}
>
<KeyRound className="size-3" />
{t("add")}
</Button>
<div className="flex flex-col divide-y divide-border border border-border">
{passkeys.length === 0 ? (
<div className="py-4 text-center text-[10px] text-muted-foreground">
{t("none")}
</div>
) : (
passkeys.map((passkey) => (
<div
key={passkey.id}
className="flex items-center justify-between gap-2 px-2 py-2"
>
<div className="min-w-0 flex flex-col">
<span className="text-xs font-medium truncate">
{passkey.name}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{passkey.deviceType || t("unknownDevice")}
{passkey.backedUp ? ` / ${t("synced")}` : ""}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => remove(passkey.id)}
disabled={loading}
aria-label={t("delete")}
>
<Trash2 className="size-3" />
</Button>
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { useMemo, useState } from "react";
import { Fingerprint } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@termix/plugin-sdk/ui";
import {
usePluginApi,
useTranslation,
type LoginMethodUIProps,
} from "@termix/plugin-sdk/frontend";
import { createWebAuthnApi, isPasskeySupported } from "./webauthn-api";
export function PasskeyLoginButton({
disabled,
submit,
username,
}: LoginMethodUIProps) {
const { t } = useTranslation();
const pluginApi = usePluginApi();
const api = useMemo(() => createWebAuthnApi(pluginApi), [pluginApi]);
const [loading, setLoading] = useState(false);
const [supported] = useState(isPasskeySupported);
if (!supported) return null;
return (
<Button
type="button"
variant="outline"
onClick={async () => {
setLoading(true);
try {
await submit(
await api.passkeyAssertion(username?.trim() || undefined),
);
} catch (err: unknown) {
const error = err as {
name?: string;
message?: string;
response?: { data?: { error?: string } };
};
// Closing the browser prompt is not a failure worth a toast.
if (error?.name === "NotAllowedError" || error?.name === "AbortError")
return;
toast.error(
error?.response?.data?.error || error?.message || t("loginFailed"),
);
} finally {
setLoading(false);
}
}}
disabled={disabled || loading}
className="w-full h-10 font-bold"
>
<span className="flex items-center gap-2">
<Fingerprint className="size-4" />
{t("signIn")}
</span>
</Button>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { PasskeyLoginButton } from "./PasskeyLoginButton";
import { PasskeyEnrollment } from "./PasskeyEnrollment";
export function activate(app: TermixApp): void {
app.registerLoginMethod({
id: "passkey",
titleKey: "title",
placement: "inline",
component: PasskeyLoginButton,
enrollment: PasskeyEnrollment,
});
}
@@ -0,0 +1,82 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/browser";
import {
browserSupportsWebAuthn,
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
export type WebAuthnCredentialSummary = {
id: string;
name: string;
deviceType?: string | null;
backedUp: boolean;
transports: string[];
userVerification: WebAuthnUserVerification;
createdAt: string;
lastUsedAt?: string | null;
};
type OptionsResponse<T> = { options: T; challengeId: string };
export function isPasskeySupported(): boolean {
try {
return browserSupportsWebAuthn();
} catch {
return false;
}
}
export function createWebAuthnApi(api: PluginApiClient) {
return {
/**
* Runs the browser ceremony and returns the body for the login method's
* verify step. Without a username, discoverable passkeys still work.
*/
async passkeyAssertion(username?: string) {
const { data } = await api.post<
OptionsResponse<PublicKeyCredentialRequestOptionsJSON>
>("authenticate/options", username ? { username } : {});
const response = await startAuthentication({
optionsJSON: data.options,
});
return {
challengeId: data.challengeId,
response: response as AuthenticationResponseJSON,
};
},
async list(): Promise<WebAuthnCredentialSummary[]> {
const { data } = await api.get<{
credentials?: WebAuthnCredentialSummary[];
}>("credentials");
return data.credentials ?? [];
},
async register(
name: string,
userVerification: WebAuthnUserVerification,
): Promise<void> {
const { data } = await api.post<
OptionsResponse<PublicKeyCredentialCreationOptionsJSON>
>("register/options", { userVerification });
const response = await startRegistration({ optionsJSON: data.options });
await api.post("register/verify", {
challengeId: data.challengeId,
name,
response: response as RegistrationResponseJSON,
});
},
async remove(id: string): Promise<void> {
await api.delete(`credentials/${encodeURIComponent(id)}`);
},
};
}