diff options
Diffstat (limited to 'app/routes')
| -rw-r--r-- | app/routes/api.passkey.tsx | 136 | ||||
| -rw-r--r-- | app/routes/auth.login.tsx | 124 | ||||
| -rw-r--r-- | app/routes/auth.register.tsx | 113 |
3 files changed, 199 insertions, 174 deletions
diff --git a/app/routes/api.passkey.tsx b/app/routes/api.passkey.tsx new file mode 100644 index 0000000..5006178 --- /dev/null +++ b/app/routes/api.passkey.tsx @@ -0,0 +1,136 @@ +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 { + saveCredential, + listCredentials, + getCredentialById, + updateCredentialCounter, +} from "~/lib/db.server"; +import { rpID, rpName, origin } from "~/lib/passkey.server"; + +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 { token?: string }; + const registerToken = process.env.REGISTER_TOKEN; + if (!registerToken || body.token !== registerToken) { + return Response.json({ error: "トークンが違います" }, { status: 403 }); + } + const options = await generateRegistrationOptions({ + rpName, + rpID, + userID: new Uint8Array([109, 105, 99, 114, 111, 98, 108, 111, 103]), + userName: "admin", + userDisplayName: "admin", + attestationType: "none", + authenticatorSelection: { residentKey: "required", userVerification: "required" }, + }); + const minimalOptions = { + rp: options.rp, + user: options.user, + challenge: options.challenge, + pubKeyCredParams: options.pubKeyCredParams.filter((p) => p.alg === -7 || p.alg === -257), + }; + session.set("challenge", options.challenge); + return Response.json(minimalOptions, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "reg-verify") { + const challenge = session.get("challenge") as string | undefined; + if (!challenge) 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); + console.error("[passkey] verifyRegistrationResponse failed:", msg); + 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, + publicKey: credential.publicKey, + counter: credential.counter, + transports: (body.response as { transports?: AuthenticatorTransportFuture[] }).transports, + }); + session.unset("challenge"); + session.set("authenticated", true); + 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 credential = getCredentialById(body.id); + if (!credential) return Response.json({ error: "パスキーが見つかりません" }, { status: 400 }); + const verification = await verifyAuthenticationResponse({ + response: body, + expectedChallenge: challenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: credential.id, + publicKey: new Uint8Array(Buffer.from(credential.public_key, "base64url")), + counter: credential.counter, + transports: credential.transports + ? (JSON.parse(credential.transports) as AuthenticatorTransportFuture[]) + : undefined, + }, + }); + if (!verification.verified) { + return Response.json({ error: "認証に失敗しました" }, { status: 400 }); + } + updateCredentialCounter(credential.id, verification.authenticationInfo.newCounter); + session.unset("challenge"); + session.set("authenticated", true); + return Response.json({ ok: true }, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + return Response.json({ error: "不正なリクエスト" }, { status: 400 }); +} diff --git a/app/routes/auth.login.tsx b/app/routes/auth.login.tsx index f8e9028..c02045b 100644 --- a/app/routes/auth.login.tsx +++ b/app/routes/auth.login.tsx @@ -1,15 +1,9 @@ import { useState } from "react"; import { redirect } from "react-router"; -import { - generateAuthenticationOptions, - verifyAuthenticationResponse, - type AuthenticationResponseJSON, - type AuthenticatorTransportFuture, -} from "@simplewebauthn/server"; +import type { AuthenticationResponseJSON, AuthenticatorTransportFuture } from "@simplewebauthn/server"; import type { Route } from "./+types/auth.login"; -import { getSession, commitSession } from "~/lib/session.server"; -import { listCredentials, getCredentialById, updateCredentialCounter } from "~/lib/db.server"; -import { rpID, origin } from "~/lib/passkey.server"; +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")); @@ -18,65 +12,11 @@ export async function loader({ request }: Route.LoaderArgs) { return {}; } -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 === "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 === "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 credential = getCredentialById(body.id); - if (!credential) return Response.json({ error: "パスキーが見つかりません" }, { status: 400 }); - - const verification = await verifyAuthenticationResponse({ - response: body, - expectedChallenge: challenge, - expectedOrigin: origin, - expectedRPID: rpID, - credential: { - id: credential.id, - publicKey: new Uint8Array(Buffer.from(credential.public_key, "base64url")), - counter: credential.counter, - transports: credential.transports - ? (JSON.parse(credential.transports) as AuthenticatorTransportFuture[]) - : undefined, - }, - }); - - if (!verification.verified) { - return Response.json({ error: "認証に失敗しました" }, { status: 400 }); - } - - updateCredentialCounter(credential.id, verification.authenticationInfo.newCounter); - session.unset("challenge"); - session.set("authenticated", true); - return redirect("/", { - headers: { "Set-Cookie": await commitSession(session) }, - }); - } - - return Response.json({ error: "不正なリクエスト" }, { status: 400 }); +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() { @@ -87,15 +27,51 @@ export default function Login() { setStatus("loading"); setErrorMsg(""); try { - const { startAuthentication } = await import("@simplewebauthn/browser"); - - const optRes = await fetch("/auth/login?intent=options", { method: "POST" }); + const optRes = await fetch("/api/passkey?intent=login-options", { method: "POST" }); if (!optRes.ok) throw new Error("オプション取得に失敗しました"); const options = await optRes.json(); - const authResponse = await startAuthentication({ optionsJSON: options }); - - const verRes = await fetch("/auth/login?intent=verify", { + 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), diff --git a/app/routes/auth.register.tsx b/app/routes/auth.register.tsx index 43e9c71..7258d38 100644 --- a/app/routes/auth.register.tsx +++ b/app/routes/auth.register.tsx @@ -1,15 +1,8 @@ import { useState } from "react"; import { redirect } from "react-router"; -import { - generateRegistrationOptions, - verifyRegistrationResponse, - type RegistrationResponseJSON, - type AuthenticatorTransportFuture, -} from "@simplewebauthn/server"; import type { Route } from "./+types/auth.register"; -import { getSession, commitSession } from "~/lib/session.server"; -import { saveCredential } from "~/lib/db.server"; -import { rpID, rpName, origin } from "~/lib/passkey.server"; +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")); @@ -17,86 +10,6 @@ export async function loader({ request }: Route.LoaderArgs) { return {}; } -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 === "options") { - const body = await request.json() as { token?: string }; - const registerToken = process.env.REGISTER_TOKEN; - if (!registerToken || body.token !== registerToken) { - return Response.json({ error: "トークンが違います" }, { status: 403 }); - } - - const options = await generateRegistrationOptions({ - rpName, - rpID, - userID: new Uint8Array([109, 105, 99, 114, 111, 98, 108, 111, 103]), // "microblog" - userName: "admin", - userDisplayName: "admin", - attestationType: "none", - authenticatorSelection: { - residentKey: "required", - userVerification: "required", - }, - }); - // 必須フィールドのみ送信(@simplewebauthn/browser を使わず直接 API を叩くため) - const minimalOptions = { - rp: options.rp, - user: options.user, - challenge: options.challenge, - pubKeyCredParams: options.pubKeyCredParams.filter( - (p) => p.alg === -7 || p.alg === -257 - ), - }; - session.set("challenge", options.challenge); - return Response.json(minimalOptions, { - headers: { "Set-Cookie": await commitSession(session) }, - }); - } - - if (intent === "verify") { - const challenge = session.get("challenge") as string | undefined; - if (!challenge) 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); - console.error("[passkey] verifyRegistrationResponse failed:", msg); - 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, - publicKey: credential.publicKey, - counter: credential.counter, - transports: (body.response as { transports?: AuthenticatorTransportFuture[] }).transports, - }); - - session.unset("challenge"); - session.set("authenticated", true); - return redirect("/", { - headers: { "Set-Cookie": await commitSession(session) }, - }); - } - - return Response.json({ error: "不正なリクエスト" }, { status: 400 }); -} - function b64urlToBuffer(b64url: string): ArrayBuffer { const base64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); const pad = "=".repeat((4 - (base64.length % 4)) % 4); @@ -126,7 +39,7 @@ export default function Register() { setStep(""); try { setStep("1a. fetch 中…"); await tick(); - const optRes = await fetch("/auth/register?intent=options", { + const optRes = await fetch("/api/passkey?intent=reg-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), @@ -145,21 +58,21 @@ export default function Register() { } setStep("1d. JSON パース OK"); await tick(); - const userId = b64urlToBuffer(optJSON.user.id); - const challenge = b64urlToBuffer(optJSON.challenge); + const userId = b64urlToBuffer(optJSON.user as never as string); + const userObj = optJSON.user as Record<string, string>; + const challenge = b64urlToBuffer(optJSON.challenge as string); setStep("2. credentials.create() 呼び出し中…"); await tick(); - // rp.id / authenticatorSelection / attestation を省略して最小構成で試す const credential = await navigator.credentials.create({ publicKey: { - rp: { name: optJSON.rp.name }, + rp: { name: (optJSON.rp as Record<string, string>).name }, user: { - id: userId, - name: optJSON.user.name, - displayName: optJSON.user.displayName ?? optJSON.user.name, + id: b64urlToBuffer(userObj.id), + name: userObj.name, + displayName: userObj.displayName ?? userObj.name, }, challenge, - pubKeyCredParams: optJSON.pubKeyCredParams, + pubKeyCredParams: optJSON.pubKeyCredParams as PublicKeyCredentialParameters[], }, }) as PublicKeyCredential | null; @@ -182,7 +95,7 @@ export default function Register() { type: "public-key", }; - const verRes = await fetch("/auth/register?intent=verify", { + const verRes = await fetch("/api/passkey?intent=reg-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(regResponse), @@ -227,7 +140,7 @@ export default function Register() { {status === "loading" ? "登録中…" : "パスキーを登録"} </button> {step && ( - <p style={{ marginTop: ".75rem", fontSize: ".8rem", color: "#6b7280" }}>{step}</p> + <p style={{ marginTop: ".75rem", fontSize: ".75rem", color: "#6b7280", wordBreak: "break-all" }}>{step}</p> )} {status === "error" && ( <p className="error-msg" style={{ marginTop: ".5rem" }}>{errorMsg}</p> |
