summaryrefslogtreecommitdiff
path: root/app/routes/auth.login.tsx
blob: c02045b0d411e34b908e91a10d293866a18c65ff (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
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 (
    <div className="wrap">
      <header className="site-header">
        <h1>log</h1>
      </header>
      <div className="auth-box">
        <h2>ログイン</h2>
        <p>登録済みのパスキーで認証します。</p>
        <button className="btn" onClick={handleLogin} disabled={status === "loading"}>
          {status === "loading" ? "認証中…" : "パスキーでログイン"}
        </button>
        {status === "error" && (
          <p className="error-msg" style={{ marginTop: ".75rem" }}>{errorMsg}</p>
        )}
      </div>
    </div>
  );
}