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/api.passkey.tsx | 163 ++++++++++++++++++++++++++++ app/routes/auth.logout.tsx | 18 ++++ app/routes/auth.register.tsx | 138 ++++++++++++++++++++++++ app/routes/home.tsx | 252 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 571 insertions(+) create mode 100644 app/routes/api.passkey.tsx create mode 100644 app/routes/auth.logout.tsx create mode 100644 app/routes/auth.register.tsx create mode 100644 app/routes/home.tsx (limited to 'app/routes') diff --git a/app/routes/api.passkey.tsx b/app/routes/api.passkey.tsx new file mode 100644 index 0000000..1ab0537 --- /dev/null +++ b/app/routes/api.passkey.tsx @@ -0,0 +1,163 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + type RegistrationResponseJSON, + type AuthenticationResponseJSON, + type AuthenticatorTransportFuture, +} from "@simplewebauthn/server"; +import type { Route } from "./+types/api.passkey"; +import { getSession, commitSession } from "~/lib/session.server"; +import { + upsertUser, + getUserById, + getCredentialById, + listCredentials, + saveCredential, + updateCredentialCounter, +} from "~/lib/db.server"; +import { rpID, rpName, origin } from "~/lib/passkey.server"; + +function uuidToBytes(uuid: string): Uint8Array { + const hex = uuid.replace(/-/g, ""); + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return bytes; +} + +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 === "reg-options") { + const body = (await request.json()) as { username?: string; token?: string }; + const registerToken = process.env.REGISTER_TOKEN; + if (!registerToken || body.token !== registerToken) { + return Response.json({ error: "トークンが違います" }, { status: 403 }); + } + const username = (body.username ?? "").trim(); + if (!username) return Response.json({ error: "ユーザー名を入力してください" }, { status: 400 }); + + const userId = crypto.randomUUID(); + const user = upsertUser(userId, username); + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userID: uuidToBytes(user.id) as Uint8Array, + userName: user.username, + userDisplayName: user.username, + attestationType: "none", + authenticatorSelection: { residentKey: "required", userVerification: "required" }, + excludeCredentials: listCredentials() + .filter((c) => c.user_id === user.id) + .map((c) => ({ id: c.id })), + }); + + session.set("challenge", options.challenge); + session.set("pendingUserId", user.id); + return Response.json(options, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "reg-verify") { + const challenge = session.get("challenge") as string | undefined; + const pendingUserId = session.get("pendingUserId") as string | undefined; + if (!challenge || !pendingUserId) { + 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); + 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, + userId: pendingUserId, + publicKey: credential.publicKey, + counter: credential.counter, + transports: (body.response as { transports?: AuthenticatorTransportFuture[] }).transports, + }); + + const user = getUserById(pendingUserId)!; + session.unset("challenge"); + session.unset("pendingUserId"); + session.set("userId", user.id); + session.set("username", user.username); + return Response.json({ ok: true }, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "login-options") { + const credentials = listCredentials(); + const options = await generateAuthenticationOptions({ + rpID, + allowCredentials: credentials.map((c) => ({ + id: c.id, + transports: c.transports + ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[]) + : undefined, + })), + userVerification: "required", + }); + session.set("challenge", options.challenge); + return Response.json(options, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "login-verify") { + const challenge = session.get("challenge") as string | undefined; + if (!challenge) return Response.json({ error: "セッション切れです" }, { status: 400 }); + + const body = (await request.json()) as AuthenticationResponseJSON; + const cred = getCredentialById(body.id); + if (!cred) return Response.json({ error: "パスキーが見つかりません" }, { status: 400 }); + + const verification = await verifyAuthenticationResponse({ + response: body, + expectedChallenge: challenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: cred.id, + publicKey: new Uint8Array(Buffer.from(cred.public_key, "base64url")), + counter: cred.counter, + transports: cred.transports + ? (JSON.parse(cred.transports) as AuthenticatorTransportFuture[]) + : undefined, + }, + }); + if (!verification.verified) { + return Response.json({ error: "認証に失敗しました" }, { status: 400 }); + } + updateCredentialCounter(cred.id, verification.authenticationInfo.newCounter); + + const user = getUserById(cred.user_id)!; + session.unset("challenge"); + session.set("userId", user.id); + session.set("username", user.username); + return Response.json({ ok: true }, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + return Response.json({ error: "不正なリクエスト" }, { status: 400 }); +} diff --git a/app/routes/auth.logout.tsx b/app/routes/auth.logout.tsx new file mode 100644 index 0000000..3b60231 --- /dev/null +++ b/app/routes/auth.logout.tsx @@ -0,0 +1,18 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/auth.logout"; +import { getSession, destroySession } from "~/lib/session.server"; + +export async function loader() { + return redirect("/"); +} + +export async function action({ request }: Route.ActionArgs) { + const session = await getSession(request.headers.get("Cookie")); + return redirect("/", { + headers: { "Set-Cookie": await destroySession(session) }, + }); +} + +export default function Logout() { + return null; +} 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}

} +
+
+ ); +} diff --git a/app/routes/home.tsx b/app/routes/home.tsx new file mode 100644 index 0000000..fd3c534 --- /dev/null +++ b/app/routes/home.tsx @@ -0,0 +1,252 @@ +import { useRef, useState } from "react"; +import { Form, redirect, useNavigation, useSearchParams, Link } from "react-router"; +import type { Route } from "./+types/home"; +import { + listTodos, + createTodo, + toggleTodo, + deleteTodo, + countActiveTodos, + hasAnyCredential, +} from "~/lib/db.server"; +import { getSession, commitSession } from "~/lib/session.server"; + +export async function loader({ request }: Route.LoaderArgs) { + const session = await getSession(request.headers.get("Cookie")); + const userId = session.get("userId") as string | undefined; + const username = session.get("username") as string | undefined; + const url = new URL(request.url); + const filter = url.searchParams.get("filter") ?? "all"; + const hasCredentials = hasAnyCredential(); + + if (!userId) { + return { isAuthenticated: false, hasCredentials, todos: [], activeCount: 0, filter, username: null }; + } + + const todos = listTodos(userId, filter); + const activeCount = countActiveTodos(userId); + return { isAuthenticated: true, hasCredentials, todos, activeCount, filter, username: username ?? null }; +} + +export async function action({ request }: Route.ActionArgs) { + const session = await getSession(request.headers.get("Cookie")); + const userId = session.get("userId") as string | undefined; + if (!userId) throw new Response("Unauthorized", { status: 401 }); + + const form = await request.formData(); + const intent = String(form.get("intent") ?? ""); + + if (intent === "create") { + const title = String(form.get("title") ?? "").trim(); + if (!title) return { error: "タイトルを入力してください" }; + createTodo(userId, title); + return redirect("/", { headers: { "Set-Cookie": await commitSession(session) } }); + } + + if (intent === "toggle") { + const id = String(form.get("id") ?? ""); + const done = form.get("done") === "1"; + if (id) toggleTodo(id, userId, done); + return redirect(request.headers.get("Referer") ?? "/", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "delete") { + const id = String(form.get("id") ?? ""); + if (id) deleteTodo(id, userId); + return redirect(request.headers.get("Referer") ?? "/", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + return { error: "不正なリクエスト" }; +} + +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, ""); +} + +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; +} + +export default function Home({ loaderData, actionData }: Route.ComponentProps) { + const { isAuthenticated, hasCredentials, todos, activeCount, filter, username } = loaderData; + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + const inputRef = useRef(null); + const [searchParams] = useSearchParams(); + const [loginStatus, setLoginStatus] = useState<"idle" | "loading" | "error">("idle"); + const [loginError, setLoginError] = useState(""); + + async function handleLogin() { + setLoginStatus("loading"); + setLoginError(""); + try { + const optRes = await fetch("/api/passkey?intent=login-options", { method: "POST" }); + if (!optRes.ok) throw new Error("オプション取得に失敗しました"); + const options = await optRes.json() as Record; + + const credential = await navigator.credentials.get({ + publicKey: { + challenge: b64urlToBuffer(options.challenge as string), + rpId: options.rpId as string, + allowCredentials: (options.allowCredentials as Array<{ id: string; transports?: string[] }> ?? []).map((c) => ({ + id: b64urlToBuffer(c.id), + type: "public-key" as const, + transports: c.transports as AuthenticatorTransport[] | undefined, + })), + userVerification: "required" as UserVerificationRequirement, + timeout: 60000, + }, + }) as PublicKeyCredential | null; + + if (!credential) throw new Error("認証に失敗しました"); + + const assertion = credential.response as AuthenticatorAssertionResponse; + const verRes = await fetch("/api/passkey?intent=login-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: credential.id, + rawId: bufferToB64url(credential.rawId), + response: { + clientDataJSON: bufferToB64url(assertion.clientDataJSON), + authenticatorData: bufferToB64url(assertion.authenticatorData), + signature: bufferToB64url(assertion.signature), + userHandle: assertion.userHandle ? bufferToB64url(assertion.userHandle) : undefined, + }, + authenticatorAttachment: credential.authenticatorAttachment ?? undefined, + clientExtensionResults: credential.getClientExtensionResults(), + type: "public-key", + }), + }); + if (!verRes.ok) { + const err = await verRes.json() as { error?: string }; + throw new Error(err.error ?? "認証に失敗しました"); + } + window.location.href = "/"; + } catch (e) { + setLoginStatus("error"); + setLoginError(e instanceof Error ? e.message : "エラーが発生しました"); + } + } + + const currentFilter = searchParams.get("filter") ?? "all"; + + if (!isAuthenticated) { + return ( +
+
+

todo

+
+
+ {hasCredentials ? ( + <> + + {loginStatus === "error" &&

{loginError}

} + + ) : ( + はじめる + )} +
+
+ ); + } + + return ( +
+
+

todo

+ +
+ +
+
{ + // Reset input after successful submit + setTimeout(() => inputRef.current?.focus(), 50); + }} + > + +
+ + +
+
+ {actionData && "error" in actionData &&

{actionData.error}

} +
+ + + +
    + {todos.length === 0 && ( +
  • + {filter === "done" ? "完了したタスクはありません" : filter === "active" ? "未完了のタスクはありません" : "タスクがありません"} +
  • + )} + {todos.map((todo) => ( +
  • +
    + + + + +
    + {todo.title} +
    + + + +
    +
  • + ))} +
+ + {todos.length > 0 && ( + + )} +
+ ); +} -- cgit v1.2.3