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.login.tsx | 124 +++++++++++++++++++---------------------------
 1 file changed, 50 insertions(+), 74 deletions(-)

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

diff --git a/app/routes/auth.login.tsx b/app/routes/auth.login.tsx
index f8e9028..c02045b 100644
--- a/app/routes/auth.login.tsx
+++ b/app/routes/auth.login.tsx
@@ -1,15 +1,9 @@
 import { useState } from "react";
 import { redirect } from "react-router";
-import {
-  generateAuthenticationOptions,
-  verifyAuthenticationResponse,
-  type AuthenticationResponseJSON,
-  type AuthenticatorTransportFuture,
-} from "@simplewebauthn/server";
+import type { AuthenticationResponseJSON, AuthenticatorTransportFuture } from "@simplewebauthn/server";
 import type { Route } from "./+types/auth.login";
-import { getSession, commitSession } from "~/lib/session.server";
-import { listCredentials, getCredentialById, updateCredentialCounter } from "~/lib/db.server";
-import { rpID, origin } from "~/lib/passkey.server";
+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"));
@@ -18,65 +12,11 @@ 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 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 === "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 credential = getCredentialById(body.id);
-    if (!credential) return Response.json({ error: "パスキーが見つかりません" }, { status: 400 });
-
-    const verification = await verifyAuthenticationResponse({
-      response: body,
-      expectedChallenge: challenge,
-      expectedOrigin: origin,
-      expectedRPID: rpID,
-      credential: {
-        id: credential.id,
-        publicKey: new Uint8Array(Buffer.from(credential.public_key, "base64url")),
-        counter: credential.counter,
-        transports: credential.transports
-          ? (JSON.parse(credential.transports) as AuthenticatorTransportFuture[])
-          : undefined,
-      },
-    });
-
-    if (!verification.verified) {
-      return Response.json({ error: "認証に失敗しました" }, { status: 400 });
-    }
-
-    updateCredentialCounter(credential.id, verification.authenticationInfo.newCounter);
-    session.unset("challenge");
-    session.set("authenticated", true);
-    return redirect("/", {
-      headers: { "Set-Cookie": await commitSession(session) },
-    });
-  }
-
-  return Response.json({ error: "不正なリクエスト" }, { status: 400 });
+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() {
@@ -87,15 +27,51 @@ export default function Login() {
     setStatus("loading");
     setErrorMsg("");
     try {
-      const { startAuthentication } = await import("@simplewebauthn/browser");
-
-      const optRes = await fetch("/auth/login?intent=options", { method: "POST" });
+      const optRes = await fetch("/api/passkey?intent=login-options", { method: "POST" });
       if (!optRes.ok) throw new Error("オプション取得に失敗しました");
       const options = await optRes.json();
 
-      const authResponse = await startAuthentication({ optionsJSON: options });
-
-      const verRes = await fetch("/auth/login?intent=verify", {
+      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),
-- 
cgit v1.2.3

