summaryrefslogtreecommitdiff
path: root/app/routes/auth.register.tsx
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-20 00:43:10 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-20 00:43:10 +0900
commitabe09e38fb5dc5ede5abe715520c3e4006c66894 (patch)
tree2a4dd09ca16d9f82f3b50aa9bd9fb029f5f5f0f1 /app/routes/auth.register.tsx
parent3ec8bce7073bd922096681348fa86b6e8ff9b0d1 (diff)
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>
Diffstat (limited to 'app/routes/auth.register.tsx')
-rw-r--r--app/routes/auth.register.tsx113
1 files changed, 13 insertions, 100 deletions
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>