From 6de9ab5bab41ae4c2d8e06a72362cf6bf2375598 Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@hetzner.yyamashita.com>
Date: Sat, 20 Jun 2026 00:26:47 +0900
Subject: Bypass @simplewebauthn/browser: call WebAuthn API directly

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

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

diff --git a/app/routes/auth.register.tsx b/app/routes/auth.register.tsx
index 06f42f5..75a7d7c 100644
--- a/app/routes/auth.register.tsx
+++ b/app/routes/auth.register.tsx
@@ -41,8 +41,7 @@ export async function action({ request }: Route.ActionArgs) {
         userVerification: "required",
       },
     });
-    // iOS Safari の parseCreationOptionsFromJSON は unknown フィールドで SyntaxError を投げるため
-    // 必須フィールドのみ送信して原因を絞り込む
+    // 必須フィールドのみ送信（@simplewebauthn/browser を使わず直接 API を叩くため）
     const minimalOptions = {
       rp: options.rp,
       user: options.user,
@@ -51,7 +50,6 @@ export async function action({ request }: Route.ActionArgs) {
         (p) => p.alg === -7 || p.alg === -257
       ),
     };
-    console.log("[passkey] options sent to browser:", JSON.stringify(minimalOptions));
     session.set("challenge", options.challenge);
     return Response.json(minimalOptions, {
       headers: { "Set-Cookie": await commitSession(session) },
@@ -99,6 +97,22 @@ export async function action({ request }: Route.ActionArgs) {
   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");
@@ -108,8 +122,6 @@ export default function Register() {
     setStatus("loading");
     setErrorMsg("");
     try {
-      const { startRegistration } = await import("@simplewebauthn/browser");
-
       const optRes = await fetch("/auth/register?intent=options", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
@@ -119,9 +131,45 @@ export default function Register() {
         const err = await optRes.json();
         throw new Error(err.error ?? "オプション取得に失敗しました");
       }
-      const options = await optRes.json();
-
-      const regResponse = await startRegistration({ optionsJSON: options });
+      const optJSON = await optRes.json();
+
+      // @simplewebauthn/browser を使わず WebAuthn API を直接呼ぶ
+      const credential = await navigator.credentials.create({
+        publicKey: {
+          rp: optJSON.rp,
+          user: {
+            id: b64urlToBuffer(optJSON.user.id),
+            name: optJSON.user.name,
+            displayName: optJSON.user.displayName ?? optJSON.user.name,
+          },
+          challenge: b64urlToBuffer(optJSON.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("クレデンシャルの作成に失敗しました");
+
+      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",
-- 
cgit v1.2.3

