import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, type RegistrationResponseJSON, type AuthenticationResponseJSON, type AuthenticatorTransportFuture, } from "@simplewebauthn/server"; import type { Route } from "./+types/api.passkey"; import { getSession, commitSession } from "~/lib/session.server"; import { upsertUser, getUserById, getCredentialById, listCredentials, saveCredential, updateCredentialCounter, } from "~/lib/db.server"; import { rpID, rpName, origin } from "~/lib/passkey.server"; function uuidToBytes(uuid: string): Uint8Array { const hex = uuid.replace(/-/g, ""); const bytes = new Uint8Array(16); for (let i = 0; i < 16; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return bytes; } export async function action({ request }: Route.ActionArgs) { const url = new URL(request.url); const intent = url.searchParams.get("intent"); const session = await getSession(request.headers.get("Cookie")); if (intent === "reg-options") { const body = (await request.json()) as { username?: string; token?: string }; const registerToken = process.env.REGISTER_TOKEN; if (!registerToken || body.token !== registerToken) { return Response.json({ error: "トークンが違います" }, { status: 403 }); } const username = (body.username ?? "").trim(); if (!username) return Response.json({ error: "ユーザー名を入力してください" }, { status: 400 }); const userId = crypto.randomUUID(); const user = upsertUser(userId, username); const options = await generateRegistrationOptions({ rpName, rpID, userID: uuidToBytes(user.id) as Uint8Array, userName: user.username, userDisplayName: user.username, attestationType: "none", authenticatorSelection: { residentKey: "required", userVerification: "required" }, excludeCredentials: listCredentials() .filter((c) => c.user_id === user.id) .map((c) => ({ id: c.id })), }); session.set("challenge", options.challenge); session.set("pendingUserId", user.id); return Response.json(options, { headers: { "Set-Cookie": await commitSession(session) }, }); } if (intent === "reg-verify") { const challenge = session.get("challenge") as string | undefined; const pendingUserId = session.get("pendingUserId") as string | undefined; if (!challenge || !pendingUserId) { return Response.json({ error: "セッション切れです" }, { status: 400 }); } const body = (await request.json()) as RegistrationResponseJSON; let verification; try { verification = await verifyRegistrationResponse({ response: body, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return Response.json({ error: msg }, { status: 400 }); } if (!verification.verified || !verification.registrationInfo) { return Response.json({ error: "登録に失敗しました" }, { status: 400 }); } const { credential } = verification.registrationInfo; saveCredential({ id: credential.id, userId: pendingUserId, publicKey: credential.publicKey, counter: credential.counter, transports: (body.response as { transports?: AuthenticatorTransportFuture[] }).transports, }); const user = getUserById(pendingUserId)!; session.unset("challenge"); session.unset("pendingUserId"); session.set("userId", user.id); session.set("username", user.username); return Response.json({ ok: true }, { headers: { "Set-Cookie": await commitSession(session) }, }); } if (intent === "login-options") { const credentials = listCredentials(); const options = await generateAuthenticationOptions({ rpID, allowCredentials: credentials.map((c) => ({ id: c.id, transports: c.transports ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[]) : undefined, })), userVerification: "required", }); session.set("challenge", options.challenge); return Response.json(options, { headers: { "Set-Cookie": await commitSession(session) }, }); } if (intent === "login-verify") { const challenge = session.get("challenge") as string | undefined; if (!challenge) return Response.json({ error: "セッション切れです" }, { status: 400 }); const body = (await request.json()) as AuthenticationResponseJSON; const cred = getCredentialById(body.id); if (!cred) return Response.json({ error: "パスキーが見つかりません" }, { status: 400 }); const verification = await verifyAuthenticationResponse({ response: body, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, credential: { id: cred.id, publicKey: new Uint8Array(Buffer.from(cred.public_key, "base64url")), counter: cred.counter, transports: cred.transports ? (JSON.parse(cred.transports) as AuthenticatorTransportFuture[]) : undefined, }, }); if (!verification.verified) { return Response.json({ error: "認証に失敗しました" }, { status: 400 }); } updateCredentialCounter(cred.id, verification.authenticationInfo.newCounter); const user = getUserById(cred.user_id)!; session.unset("challenge"); session.set("userId", user.id); session.set("username", user.username); return Response.json({ ok: true }, { headers: { "Set-Cookie": await commitSession(session) }, }); } return Response.json({ error: "不正なリクエスト" }, { status: 400 }); }