From 7c80f5ce7e44b95d16e19de40de0f8079377c6b9 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sat, 22 Aug 2026 19:21:12 +0900 Subject: Initial commit: todo app with passkey auth and PWA support Co-Authored-By: Claude Sonnet 4.6 --- app/routes/auth.register.tsx | 138 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 app/routes/auth.register.tsx (limited to 'app/routes/auth.register.tsx') diff --git a/app/routes/auth.register.tsx b/app/routes/auth.register.tsx new file mode 100644 index 0000000..de254a8 --- /dev/null +++ b/app/routes/auth.register.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { redirect } from "react-router"; +import type { Route } from "./+types/auth.register"; +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")); + if (session.get("userId")) return redirect("/"); + return {}; +} + +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 [username, setUsername] = useState(""); + const [token, setToken] = useState(""); + const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); + const [errorMsg, setErrorMsg] = useState(""); + + async function handleRegister() { + setStatus("loading"); + setErrorMsg(""); + try { + const optRes = await fetch("/api/passkey?intent=reg-options", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, token }), + }); + if (!optRes.ok) { + const err = await optRes.json() as { error?: string }; + throw new Error(err.error ?? "オプション取得に失敗しました"); + } + const optJSON = await optRes.json() as Record; + + const userObj = optJSON.user as Record; + const challenge = b64urlToBuffer(optJSON.challenge as string); + + const credential = await navigator.credentials.create({ + publicKey: { + rp: optJSON.rp as PublicKeyCredentialRpEntity, + user: { + id: b64urlToBuffer(userObj.id), + name: userObj.name, + displayName: userObj.displayName ?? userObj.name, + }, + challenge, + pubKeyCredParams: optJSON.pubKeyCredParams as PublicKeyCredentialParameters[], + authenticatorSelection: optJSON.authenticatorSelection as AuthenticatorSelectionCriteria, + timeout: 60000, + }, + }) 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) as import("@simplewebauthn/server").AuthenticatorAttachment | undefined, + clientExtensionResults: credential.getClientExtensionResults(), + type: "public-key", + }; + + const verRes = await fetch("/api/passkey?intent=reg-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(regResponse), + }); + if (!verRes.ok) { + const err = await verRes.json() as { error?: string }; + throw new Error(err.error ?? "登録に失敗しました"); + } + + window.location.href = "/"; + } catch (e) { + setStatus("error"); + setErrorMsg(e instanceof Error ? `${e.name}: ${e.message}` : String(e)); + } + } + + return ( +
+
+

todo

+
+
+

アカウント登録

+

ユーザー名と登録トークンを入力してパスキーを作成します。

+
+ setUsername(e.target.value)} + autoFocus + /> + setToken(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + /> +
+ + {status === "error" &&

{errorMsg}

} +
+
+ ); +} -- cgit v1.2.3