import { useState } from "react"; import { redirect } from "react-router"; import type { AuthenticationResponseJSON, AuthenticatorTransportFuture } from "@simplewebauthn/server"; import type { Route } from "./+types/auth.login"; import { getSession } from "~/lib/session.server"; import { listCredentials } from "~/lib/db.server"; export async function loader({ request }: Route.LoaderArgs) { const session = await getSession(request.headers.get("Cookie")); if (session.get("authenticated")) return redirect("/"); if (listCredentials().length === 0) return redirect("/auth/register"); return {}; } 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 Login() { const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); const [errorMsg, setErrorMsg] = useState(""); async function handleLogin() { setStatus("loading"); setErrorMsg(""); try { const optRes = await fetch("/api/passkey?intent=login-options", { method: "POST" }); if (!optRes.ok) throw new Error("オプション取得に失敗しました"); const options = await optRes.json(); const 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; }; const credential = await navigator.credentials.get({ publicKey: { challenge: b64urlToBuffer(options.challenge as string), rpId: options.rpId as string, allowCredentials: (options.allowCredentials as Array<{ id: string; transports?: string[] }> ?? []).map((c) => ({ id: b64urlToBuffer(c.id), type: "public-key" as const, transports: c.transports as AuthenticatorTransport[] | undefined, })), userVerification: "required" as UserVerificationRequirement, timeout: 60000, }, }) as PublicKeyCredential | null; if (!credential) throw new Error("認証に失敗しました"); const assertion = credential.response as AuthenticatorAssertionResponse; const authResponse: AuthenticationResponseJSON = { id: credential.id, rawId: bufferToB64url(credential.rawId), response: { clientDataJSON: bufferToB64url(assertion.clientDataJSON), authenticatorData: bufferToB64url(assertion.authenticatorData), signature: bufferToB64url(assertion.signature), userHandle: assertion.userHandle ? bufferToB64url(assertion.userHandle) : undefined, }, authenticatorAttachment: credential.authenticatorAttachment ?? undefined, clientExtensionResults: credential.getClientExtensionResults(), type: "public-key", }; const verRes = await fetch("/api/passkey?intent=login-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(authResponse), }); 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.message : "エラーが発生しました"); } } return (
登録済みのパスキーで認証します。
{status === "error" && ({errorMsg}
)}