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>
);
}
|