diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-22 19:21:12 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-22 19:21:12 +0900 |
| commit | 7c80f5ce7e44b95d16e19de40de0f8079377c6b9 (patch) | |
| tree | ad189b750361af8c7cb040b56f42df5d4e5a89ee /app/routes/auth.register.tsx | |
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.tsx | 138 |
1 files changed, 138 insertions, 0 deletions
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<string, unknown>; + + const userObj = optJSON.user as Record<string, string>; + 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 ( + <div className="wrap"> + <header className="site-header"> + <h1>todo</h1> + </header> + <div className="auth-box"> + <h2>アカウント登録</h2> + <p>ユーザー名と登録トークンを入力してパスキーを作成します。</p> + <div className="field-group"> + <input + type="text" + placeholder="ユーザー名" + value={username} + onChange={(e) => setUsername(e.target.value)} + autoFocus + /> + <input + type="password" + placeholder="登録トークン" + value={token} + onChange={(e) => setToken(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + /> + </div> + <button + className="btn" + onClick={handleRegister} + disabled={status === "loading" || !username || !token} + > + {status === "loading" ? "登録中…" : "パスキーを登録"} + </button> + {status === "error" && <p className="error-msg">{errorMsg}</p>} + </div> + </div> + ); +} |
