summaryrefslogtreecommitdiff
path: root/app/routes/api.passkey.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes/api.passkey.tsx')
-rw-r--r--app/routes/api.passkey.tsx163
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 });
+}