From 85e06567f3054b6fc7349aa1f65809db47c9034a Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@hetzner.yyamashita.com>
Date: Fri, 19 Jun 2026 23:26:55 +0900
Subject: Require REGISTER_TOKEN to create a passkey

Prevents anyone who finds /auth/register from registering.
Token is set via REGISTER_TOKEN env var on the server.

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

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

diff --git a/app/routes/auth.register.tsx b/app/routes/auth.register.tsx
index cc4e6a9..c064eff 100644
--- a/app/routes/auth.register.tsx
+++ b/app/routes/auth.register.tsx
@@ -1,5 +1,5 @@
 import { useState } from "react";
-import { Link, redirect } from "react-router";
+import { redirect } from "react-router";
 import {
   generateRegistrationOptions,
   verifyRegistrationResponse,
@@ -8,14 +8,13 @@ import {
 } from "@simplewebauthn/server";
 import type { Route } from "./+types/auth.register";
 import { getSession, commitSession } from "~/lib/session.server";
-import { listCredentials, saveCredential } from "~/lib/db.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("/");
-  const hasCredentials = listCredentials().length > 0;
-  return { hasCredentials };
+  return {};
 }
 
 export async function action({ request }: Route.ActionArgs) {
@@ -24,6 +23,12 @@ export async function action({ request }: Route.ActionArgs) {
   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,
@@ -74,8 +79,8 @@ export async function action({ request }: Route.ActionArgs) {
   return Response.json({ error: "不正なリクエスト" }, { status: 400 });
 }
 
-export default function Register({ loaderData }: Route.ComponentProps) {
-  const { hasCredentials } = loaderData;
+export default function Register() {
+  const [token, setToken] = useState("");
   const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
   const [errorMsg, setErrorMsg] = useState("");
 
@@ -85,8 +90,15 @@ export default function Register({ loaderData }: Route.ComponentProps) {
     try {
       const { startRegistration } = await import("@simplewebauthn/browser");
 
-      const optRes = await fetch("/auth/register?intent=options", { method: "POST" });
-      if (!optRes.ok) throw new Error("オプション取得に失敗しました");
+      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 options = await optRes.json();
 
       const regResponse = await startRegistration({ optionsJSON: options });
@@ -115,24 +127,28 @@ export default function Register({ loaderData }: Route.ComponentProps) {
       </header>
       <div className="auth-box">
         <h2>パスキーを登録</h2>
-        {hasCredentials ? (
-          <>
-            <p>すでにパスキーが登録されています。</p>
-            <Link to="/auth/login" className="btn" style={{ textDecoration: "none" }}>
-              ログインへ
-            </Link>
-          </>
-        ) : (
-          <>
-            <p>
-              このデバイスの生体認証（Touch ID / Face ID など）でパスキーを作成します。
-              一度登録すると、次回からはパスキーでログインできます。
-            </p>
-            <button className="btn" onClick={handleRegister} disabled={status === "loading"}>
-              {status === "loading" ? "登録中…" : "パスキーを登録"}
-            </button>
-            {status === "error" && <p className="error-msg" style={{ marginTop: ".75rem" }}>{errorMsg}</p>}
-          </>
+        <p>登録トークンを入力してパスキーを作成します。</p>
+        <input
+          type="password"
+          placeholder="登録トークン"
+          value={token}
+          onChange={(e) => 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",
+          }}
+        />
+        <button className="btn" onClick={handleRegister} disabled={status === "loading" || !token}>
+          {status === "loading" ? "登録中…" : "パスキーを登録"}
+        </button>
+        {status === "error" && (
+          <p className="error-msg" style={{ marginTop: ".75rem" }}>{errorMsg}</p>
         )}
       </div>
     </div>
-- 
cgit v1.2.3

