import { useState } from "react"; import { redirect } from "react-router"; import { generateRegistrationOptions, verifyRegistrationResponse, type RegistrationResponseJSON, type AuthenticatorTransportFuture, } from "@simplewebauthn/server"; import type { Route } from "./+types/auth.register"; import { getSession, commitSession } from "~/lib/session.server"; import { saveCredential } from "~/lib/db.server"; import { rpID, rpName, origin } from "~/lib/passkey.server"; export async function loader({ request }: Route.LoaderArgs) { const session = await getSession(request.headers.get("Cookie")); if (session.get("authenticated")) return redirect("/"); return {}; } 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 === "options") { const body = await request.json() as { token?: string }; const registerToken = process.env.REGISTER_TOKEN; if (!registerToken || body.token !== registerToken) { return Response.json({ error: "トークンが違います" }, { status: 403 }); } const options = await generateRegistrationOptions({ rpName, rpID, userID: new Uint8Array([109, 105, 99, 114, 111, 98, 108, 111, 103]), // "microblog" userName: "admin", userDisplayName: "admin", attestationType: "none", authenticatorSelection: { residentKey: "required", userVerification: "required", }, }); // 必須フィールドのみ送信(@simplewebauthn/browser を使わず直接 API を叩くため) const minimalOptions = { rp: options.rp, user: options.user, challenge: options.challenge, pubKeyCredParams: options.pubKeyCredParams.filter( (p) => p.alg === -7 || p.alg === -257 ), }; session.set("challenge", options.challenge); return Response.json(minimalOptions, { headers: { "Set-Cookie": await commitSession(session) }, }); } if (intent === "verify") { const challenge = session.get("challenge") as string | undefined; if (!challenge) 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); console.error("[passkey] verifyRegistrationResponse failed:", msg); 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, publicKey: credential.publicKey, counter: credential.counter, transports: (body.response as { transports?: AuthenticatorTransportFuture[] }).transports, }); session.unset("challenge"); session.set("authenticated", true); return redirect("/", { headers: { "Set-Cookie": await commitSession(session) }, }); } return Response.json({ error: "不正なリクエスト" }, { status: 400 }); } function b64urlToBuffer(b64url: string): ArrayBuffer { const base64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); const pad = "=".repeat((4 - (base64.length % 4)) % 4); const binary = atob(base64 + pad); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } function bufferToB64url(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf); let binary = ""; for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } export default function Register() { const [token, setToken] = useState(""); const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); const [errorMsg, setErrorMsg] = useState(""); const [step, setStep] = useState(""); async function handleRegister() { setStatus("loading"); setErrorMsg(""); setStep(""); try { setStep("1. options 取得中…"); const optRes = await fetch("/auth/register?intent=options", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); if (!optRes.ok) { const err = await optRes.json(); throw new Error(err.error ?? "オプション取得に失敗しました"); } const optJSON = await optRes.json(); setStep("2. options 受信 OK → credentials.create() 呼び出し中…"); const userId = b64urlToBuffer(optJSON.user.id); const challenge = b64urlToBuffer(optJSON.challenge); const credential = await navigator.credentials.create({ publicKey: { rp: optJSON.rp, user: { id: userId, name: optJSON.user.name, displayName: optJSON.user.displayName ?? optJSON.user.name, }, challenge, pubKeyCredParams: optJSON.pubKeyCredParams, timeout: 60000, attestation: "none", authenticatorSelection: { residentKey: "required" as ResidentKeyRequirement, userVerification: "required" as UserVerificationRequirement, }, }, }) as PublicKeyCredential | null; if (!credential) throw new Error("クレデンシャルの作成に失敗しました"); setStep("3. credential 取得 OK → サーバー送信中…"); const attestation = credential.response as AuthenticatorAttestationResponse; const regResponse: RegistrationResponseJSON = { id: credential.id, rawId: bufferToB64url(credential.rawId), response: { clientDataJSON: bufferToB64url(attestation.clientDataJSON), attestationObject: bufferToB64url(attestation.attestationObject), transports: attestation.getTransports ? (attestation.getTransports() as AuthenticatorTransportFuture[]) : [], }, authenticatorAttachment: credential.authenticatorAttachment ?? undefined, clientExtensionResults: credential.getClientExtensionResults(), type: "public-key", }; const verRes = await fetch("/auth/register?intent=verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(regResponse), }); if (!verRes.ok) { const err = await verRes.json(); throw new Error(err.error ?? "登録に失敗しました"); } window.location.href = "/"; } catch (e) { setStatus("error"); setErrorMsg(e instanceof Error ? `${e.name}: ${e.message}` : String(e)); } } return (
登録トークンを入力してパスキーを作成します。
setToken(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRegister()} style={{ width: "100%", marginBottom: ".75rem", padding: ".5rem .75rem", border: "1px solid #d1d5db", borderRadius: "4px", fontSize: ".875rem", fontFamily: "ui-monospace, monospace", }} /> {step && ({step}
)} {status === "error" && ({errorMsg}
)}