From d7a8e8a42b752aeaa22315e96b7e5fb69811fdbc Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sat, 20 Jun 2026 09:51:12 +0900 Subject: Move passkey login inline to home page, remove /auth/login route Co-Authored-By: Claude Sonnet 4.6 --- app/routes.ts | 1 - app/routes/auth.login.tsx | 108 ---------------------------------------------- app/routes/home.tsx | 87 +++++++++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 113 deletions(-) delete mode 100644 app/routes/auth.login.tsx diff --git a/app/routes.ts b/app/routes.ts index f4577e5..ec730f7 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -5,6 +5,5 @@ export default [ route("/api/posts", "routes/api.posts.tsx"), route("/api/passkey", "routes/api.passkey.tsx"), route("/auth/register", "routes/auth.register.tsx"), - route("/auth/login", "routes/auth.login.tsx"), route("/auth/logout", "routes/auth.logout.tsx"), ] satisfies RouteConfig; diff --git a/app/routes/auth.login.tsx b/app/routes/auth.login.tsx deleted file mode 100644 index c02045b..0000000 --- a/app/routes/auth.login.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useState } from "react"; -import { redirect } from "react-router"; -import type { AuthenticationResponseJSON, AuthenticatorTransportFuture } from "@simplewebauthn/server"; -import type { Route } from "./+types/auth.login"; -import { getSession } from "~/lib/session.server"; -import { listCredentials } from "~/lib/db.server"; - -export async function loader({ request }: Route.LoaderArgs) { - const session = await getSession(request.headers.get("Cookie")); - if (session.get("authenticated")) return redirect("/"); - if (listCredentials().length === 0) return redirect("/auth/register"); - return {}; -} - -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 Login() { - const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); - const [errorMsg, setErrorMsg] = useState(""); - - async function handleLogin() { - setStatus("loading"); - setErrorMsg(""); - try { - const optRes = await fetch("/api/passkey?intent=login-options", { method: "POST" }); - if (!optRes.ok) throw new Error("オプション取得に失敗しました"); - const options = await optRes.json(); - - const 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; - }; - - 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 authResponse: AuthenticationResponseJSON = { - 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", - }; - - const verRes = await fetch("/api/passkey?intent=login-verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(authResponse), - }); - if (!verRes.ok) { - const err = await verRes.json(); - throw new Error(err.error ?? "認証に失敗しました"); - } - - window.location.href = "/"; - } catch (e) { - setStatus("error"); - setErrorMsg(e instanceof Error ? e.message : "エラーが発生しました"); - } - } - - return ( -
-
-

log

-
-
-

ログイン

-

登録済みのパスキーで認証します。

- - {status === "error" && ( -

{errorMsg}

- )} -
-
- ); -} diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 7732326..9bea992 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from "react"; -import { Form, Link, redirect, useNavigation } from "react-router"; +import { useEffect, useRef, useState } from "react"; +import { Form, redirect, useNavigation } from "react-router"; import type { Route } from "./+types/home"; import { createPost, deletePost, listPosts, listCredentials } from "~/lib/db.server"; import { renderMarkdown } from "~/lib/md.server"; @@ -44,12 +44,84 @@ export async function action({ request }: Route.ActionArgs) { }); } +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 { posts, isAuthenticated, hasCredentials } = loaderData; const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; const formRef = useRef(null); const wasSubmitting = useRef(false); + 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(); + + 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(); + throw new Error(err.error ?? "認証に失敗しました"); + } + + window.location.href = "/"; + } catch (e) { + setLoginStatus("error"); + setLoginError(e instanceof Error ? e.message : "エラーが発生しました"); + } + } useEffect(() => { if (navigation.state === "submitting") wasSubmitting.current = true; @@ -69,9 +141,16 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) { ) : hasCredentials ? ( - ログイン + <> + + {loginStatus === "error" && ( + {loginError} + )} + ) : ( - 登録 + 登録 )} -- cgit v1.2.3