summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/routes.ts1
-rw-r--r--app/routes/api.passkey.tsx136
-rw-r--r--app/routes/auth.login.tsx124
-rw-r--r--app/routes/auth.register.tsx113
4 files changed, 200 insertions, 174 deletions
diff --git a/app/routes.ts b/app/routes.ts
index 8ca1209..f4577e5 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -3,6 +3,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
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"),
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>