From abe09e38fb5dc5ede5abe715520c3e4006c66894 Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@hetzner.yyamashita.com>
Date: Sat, 20 Jun 2026 00:43:10 +0900
Subject: Fix: move passkey API to dedicated route without React component

React Router v7 treats POST to routes with a default export as document
requests and returns HTML instead of the action's JSON response. Moving
the API logic to an action-only route (/api/passkey) fixes this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 app/routes/auth.register.tsx | 113 +++++--------------------------------------
 1 file changed, 13 insertions(+), 100 deletions(-)

(limited to 'app/routes/auth.register.tsx')

diff --git a/app/routes/auth.register.tsx b/app/routes/auth.register.tsx
index 43e9c71..7258d38 100644
--- a/app/routes/auth.register.tsx
+++ b/app/routes/auth.register.tsx
@@ -1,15 +1,8 @@
 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";
+import { getSession } from "~/lib/session.server";
+import type { RegistrationResponseJSON, AuthenticatorTransportFuture } from "@simplewebauthn/server";
 
 export async function loader({ request }: Route.LoaderArgs) {
   const session = await getSession(request.headers.get("Cookie"));
@@ -17,86 +10,6 @@ export async function loader({ request }: Route.LoaderArgs) {
   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);
@@ -126,7 +39,7 @@ export default function Register() {
     setStep("");
     try {
       setStep("1a. fetch 中…"); await tick();
-      const optRes = await fetch("/auth/register?intent=options", {
+      const optRes = await fetch("/api/passkey?intent=reg-options", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ token }),
@@ -145,21 +58,21 @@ export default function Register() {
       }
       setStep("1d. JSON パース OK"); await tick();
 
-      const userId = b64urlToBuffer(optJSON.user.id);
-      const challenge = b64urlToBuffer(optJSON.challenge);
+      const userId = b64urlToBuffer(optJSON.user as never as string);
+      const userObj = optJSON.user as Record<string, string>;
+      const challenge = b64urlToBuffer(optJSON.challenge as string);
       setStep("2. credentials.create() 呼び出し中…"); await tick();
 
-      // rp.id / authenticatorSelection / attestation を省略して最小構成で試す
       const credential = await navigator.credentials.create({
         publicKey: {
-          rp: { name: optJSON.rp.name },
+          rp: { name: (optJSON.rp as Record<string, string>).name },
           user: {
-            id: userId,
-            name: optJSON.user.name,
-            displayName: optJSON.user.displayName ?? optJSON.user.name,
+            id: b64urlToBuffer(userObj.id),
+            name: userObj.name,
+            displayName: userObj.displayName ?? userObj.name,
           },
           challenge,
-          pubKeyCredParams: optJSON.pubKeyCredParams,
+          pubKeyCredParams: optJSON.pubKeyCredParams as PublicKeyCredentialParameters[],
         },
       }) as PublicKeyCredential | null;
 
@@ -182,7 +95,7 @@ export default function Register() {
         type: "public-key",
       };
 
-      const verRes = await fetch("/auth/register?intent=verify", {
+      const verRes = await fetch("/api/passkey?intent=reg-verify", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify(regResponse),
@@ -227,7 +140,7 @@ export default function Register() {
           {status === "loading" ? "登録中…" : "パスキーを登録"}
         </button>
         {step && (
-          <p style={{ marginTop: ".75rem", fontSize: ".8rem", color: "#6b7280" }}>{step}</p>
+          <p style={{ marginTop: ".75rem", fontSize: ".75rem", color: "#6b7280", wordBreak: "break-all" }}>{step}</p>
         )}
         {status === "error" && (
           <p className="error-msg" style={{ marginTop: ".5rem" }}>{errorMsg}</p>
-- 
cgit v1.2.3

