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/api.passkey.tsx | |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app/routes/api.passkey.tsx')
| -rw-r--r-- | app/routes/api.passkey.tsx | 163 |
1 files changed, 163 insertions, 0 deletions
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<ArrayBuffer>, + 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 }); +} |
