summaryrefslogtreecommitdiff
path: root/app/routes/auth.register.tsx
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-08-22 19:21:12 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-08-22 19:21:12 +0900
commit7c80f5ce7e44b95d16e19de40de0f8079377c6b9 (patch)
treead189b750361af8c7cb040b56f42df5d4e5a89ee /app/routes/auth.register.tsx
Initial commit: todo app with passkey auth and PWA supportHEADmaster
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.tsx138
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>
+ );
+}