React パスキー認証を実装する④ 登録・ログイン画面をつなぐ
Reactからパスキーの登録・ログインAPIを呼び出します。
フロントエンドの役割は、サーバーが発行した設定をWebAuthnへ渡し、ブラウザーが返した結果をサーバーへ送ることです。登録先の利用者やRP IDを、画面側で独自に組み立て直さないようにします。
前回までのログインAPIと、登録APIが動く構成を前提にしています。
ローカル環境をそろえる
この連載では、ブラウザーでhttps://localhost:5173を開きます。Reactの開発サーバーから、/apiをASP.NET Coreへ転送してください。
Viteを使う場合は、既存の設定に次の項目を追加します。例ではバックエンドがhttps://localhost:7043で起動する前提です。ポートは実際の起動設定に合わせます。
vite.config.tsに追加するserver設定server: {
host: 'localhost',
port: 5173,
strictPort: true,
https: {
key: readFileSync('./certs/localhost-key.pem'),
cert: readFileSync('./certs/localhost.pem'),
},
proxy: {
'/api': {
target: 'https://localhost:7043',
changeOrigin: true,
},
},
},readFileSyncはnode:fsからインポートします。証明書は別途発行し、ブラウザーで信頼できる状態にしておきます。また、Node.js側も転送先のASP.NET Coreの証明書を信頼できる必要があります。必要に応じて開発用CAをNODE_EXTRA_CA_CERTSに指定してViteを起動してください。秘密鍵はGitへ追加しません。
strictPortは、5173が使われているときに別のポートへ自動で変わることを防ぎます。ポートが変わると、サーバーに設定したOriginと一致しなくなるためです。設定の詳細はViteのサーバーオプションを参照してください。
画面もAPIもブラウザーからは同じOriginに見えるため、以下のコードは相対URLとcredentials: 'same-origin'で呼び出せます。別OriginのAPIを直接呼ぶ構成へ変える場合は、CookieやCORSの設計も合わせて見直します。
JSONとバイト列の変換をブラウザーのAPIに任せる
HTTPで送るJSONには、バイト列をそのまま入れられません。WebAuthnでは、チャレンジやCredential IDなどをBase64URLの文字列としてやり取りします。
この例では、次のAPIを使います。
| API | 用途 |
|---|---|
PublicKeyCredential.parseCreationOptionsFromJSON | 登録用JSONをWebAuthnの入力へ変換する |
PublicKeyCredential.parseRequestOptionsFromJSON | ログイン用JSONをWebAuthnの入力へ変換する |
credential.toJSON | 登録・ログインの結果を送信用JSONへ変換する |
これにより、チャレンジだけ変換してUserHandleの変換を忘れる、といったミスを減らせます。登録用とログイン用では、変換する項目も異なります。登録用の変換、ログイン用の変換、結果のJSON化の仕様も確認できます。
パスキー自体を利用できるブラウザーでも、このJSON変換APIがない場合があります。以下のコードでは機能の有無を確認し、未対応の場合は案内を表示します。
古い環境まで対応する場合は、対応範囲を決めて変換ライブラリなどを導入してください。型エラーを消すためのキャストだけでは、ブラウザーに機能は追加されません。掲載コードの型確認にはTypeScript 5.9.3のDOM型を使用しています。
APIとWebAuthnをつなぐ関数を作る
コンポーネントの中へHTTP通信をすべて書かず、次のように分けます。
- src
- features
- passkeys
- passkey.ts
- PasskeyButton.tsx
- passkeys
- features
まず、通信とブラウザーの認証操作をpasskey.tsにまとめます。
passkey.tsfunction ensureWebAuthnJsonSupport(): void {
if (
!window.isSecureContext ||
typeof PublicKeyCredential === 'undefined' ||
typeof PublicKeyCredential.parseCreationOptionsFromJSON !== 'function' ||
typeof PublicKeyCredential.parseRequestOptionsFromJSON !== 'function' ||
typeof PublicKeyCredential.prototype.toJSON !== 'function'
) {
throw new Error('この環境ではパスキーを利用できません。対応ブラウザーをご利用ください。');
}
}
async function getCsrfToken(): Promise<string> {
const response = await fetch('/api/passkeys/csrf', {
credentials: 'same-origin',
cache: 'no-store',
});
if (!response.ok) throw new Error('認証の準備に失敗しました。');
const data: { token: string } = await response.json();
return data.token;
}
async function post(path: string, token: string, body: unknown): Promise<Response> {
const response = await fetch(path, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': token,
},
body: JSON.stringify(body),
});
if (response.status === 401) throw new Error('ログインし直してから登録してください。');
if (!response.ok) throw new Error('処理を完了できませんでした。最初からお試しください。');
return response;
}
export async function registerPasskey(): Promise<void> {
ensureWebAuthnJsonSupport();
const token = await getCsrfToken();
const response = await post('/api/passkeys/register/options', token, {});
const data: {
flowId: string;
publicKey: PublicKeyCredentialCreationOptionsJSON;
} = await response.json();
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(data.publicKey);
const credential = await navigator.credentials.create({ publicKey });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('パスキーを作成できませんでした。');
}
await post('/api/passkeys/register/finish', token, {
flowId: data.flowId,
credential: credential.toJSON(),
});
}
export async function loginWithPasskey(): Promise<void> {
ensureWebAuthnJsonSupport();
const token = await getCsrfToken();
const response = await post('/api/passkeys/login/options', token, {});
const data: {
flowId: string;
publicKey: PublicKeyCredentialRequestOptionsJSON;
} = await response.json();
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(data.publicKey);
const credential = await navigator.credentials.get({ publicKey });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('パスキーで認証できませんでした。');
}
await post('/api/passkeys/login/finish', token, {
flowId: data.flowId,
credential: credential.toJSON(),
});
}
export function passkeyErrorMessage(error: unknown): string {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
return '操作が完了しませんでした。キャンセルや時間切れの場合は、もう一度お試しください。';
}
if (error instanceof DOMException && error.name === 'InvalidStateError') {
return 'この認証器には登録済みのパスキーがある可能性があります。';
}
if (error instanceof DOMException && error.name === 'SecurityError') {
return 'このサイトの設定ではパスキーを利用できません。';
}
if (error instanceof DOMException) return 'パスキーの操作に失敗しました。';
return error instanceof Error ? error.message : '処理に失敗しました。';
}登録は、次の順番です。
- CSRFトークンを取得する
register/optionsで登録設定を取得する- JSONを変換して
navigator.credentials.createへ渡す - 結果をJSONへ変換して
register/finishへ送る
ログインも同じ形で、navigator.credentials.getを使います。
publicKeyの中身はサーバーの設定をそのまま使います。たとえばrp.idをwindow.location.hostnameから作り直すと、サーバー側で決めたRP IDと食い違う可能性があります。許可するアルゴリズムやユーザー検証の条件も、画面側に別の定義を持たせません。
完了APIは204 No Contentを返すため、成功時にresponse.json()を呼びません。空のレスポンスをJSONとして読もうとすると、認証に成功していても画面側でエラーになります。
ボタンから呼び出す
コンポーネントでは、実行中の状態と結果のメッセージを管理します。
PasskeyButton.tsximport { useRef, useState } from 'react';
import { loginWithPasskey, passkeyErrorMessage, registerPasskey } from './passkey';
type Props = { mode: 'register' | 'login' };
export function PasskeyButton({ mode }: Props) {
const running = useRef(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
async function handleClick() {
if (running.current) return;
running.current = true;
setBusy(true);
setMessage('');
try {
if (mode === 'register') {
await registerPasskey();
setMessage('パスキーを登録しました。');
} else {
await loginWithPasskey();
window.location.reload();
}
} catch (error) {
setMessage(passkeyErrorMessage(error));
} finally {
running.current = false;
setBusy(false);
}
}
return (
<div>
<button type="button" disabled={busy} onClick={handleClick}>
{busy ? '処理中…' : mode === 'register' ? 'パスキーを登録' : 'パスキーでログイン'}
</button>
<p role="status">{message}</p>
</div>
);
}設定画面には<PasskeyButton mode="register" />、ログイン画面には<PasskeyButton mode="login" />を置きます。
ボタンのdisabledに加え、useRefでも実行中の状態を確認しています。Reactの再描画前に続けてイベントが来ても、同じコンポーネントから認証操作を重ねて開始しないためです。
これは画面操作の重複を減らす処理です。サーバー側の使い捨てチャレンジも引き続き必要です。
ログイン成功後は画面を再読み込みしています。既存のアプリでログイン中の利用者を管理している場合は、その状態を再取得する処理へ置き換えてください。匿名状態で取得したCSRFトークンも、次の操作では取り直します。
キャンセルを決め付けないエラー表示にする
NotAllowedErrorは、利用者がキャンセルした場合だけに限りません。時間切れなどでも発生するため、「キャンセルしました」と断定せず、「操作が完了しませんでした」と表示しています。
SecurityErrorの場合は、RP IDやOrigin、HTTPSの設定を確認します。利用者へは内部設定を並べず、開発者がブラウザーのコンソールやサーバーのログで調べられるようにします。
また、ネットワークエラーと認証の拒否は別です。サーバーが登録を保存したあとにレスポンスだけ届かない場合もあるため、通信失敗を「絶対に登録されていない」と読み替えないようにします。実際の設定画面では、登録済みパスキーの一覧を再取得して確認できると扱いやすくなります。
登録からログインまで通して確認する
まず、次の順番で実機確認します。
- 既存のログイン方法で、自分のアカウントにログインする
- 設定画面でパスキーを登録する
- サーバーにCredential ID、公開鍵、利用者IDが保存されたことを確認する
- アプリの既存のログアウト処理を実行する
- 「パスキーでログイン」を押し、登録したアカウントを選ぶ
- 認証が必要なAPIを呼び、正しい利用者としてログインできたことを確認する
Cookie認証では、ログインAPIの204だけを見るのではなく、次のAPI呼び出しでも認証状態が引き継がれることを確認します。
次に、失敗させる条件も確認します。
| 確認する操作 | 期待する結果 |
|---|---|
| 未ログインで登録開始APIを呼ぶ | 401で拒否される |
| CSRFヘッダーなしでPOSTする | 拒否される |
| 認証画面をキャンセルする | 完了表示にならず、ボタンを再度押せる |
| 同じ完了リクエストを2回送る | 2回目は拒否される |
| 開始から5分以上たって完了APIを呼ぶ | 期限切れとして拒否される |
| 別ブラウザーへflowIdと認証結果をコピーする | 操作Cookieが異なるため拒否される |
| 登録用flowIdをログイン完了APIへ渡す | 用途の不一致で拒否される |
| 登録開始後に別の利用者へログインし直す | 元の利用者への登録は成功しない |
| 署名の一部を変更して送る | 検証に失敗し、認証Cookieは発行されない |
| 削除済みのパスキーや無効な会員でログインする | ログインできない |
サーバーの拒否条件を確認するときは、ブラウザーの認証画面が先に処理を止める場合と区別します。期限切れや用途違いなどは、テスト用クライアントでAPIを直接呼ぶと確かめやすくなります。別ブラウザーのテストでは、そのブラウザーで取得したCSRFトークンを使い、CSRFの失敗だけでテストを終えないようにします。
自動化する場合は、仮想認証器を利用できます。ただし、同期型パスキーの選択画面や端末間の操作は実機でも確認します。コンパイルや型チェックだけでは、OSやブラウザーをまたぐ一連の認証が動くことまでは保証できません。
運用に必要な機能を追加する
この連載で実装したのは、既存アカウントへのパスキー登録と、登録済みパスキーでのログインです。公開する前に、利用者が自分で管理できる画面も用意します。
- 登録済みパスキーの一覧と、利用者が付ける名前
- 不要になったパスキーの削除
- 端末をなくした場合の、本人確認を伴う復旧方法
- 最後のログイン手段を削除するときの確認
パスキーを複数登録できるようにしておくと、1台の端末が使えなくなった場合にも別の手段でログインできます。別端末への追加手順を作る場合は、パスキーを別の端末に安全に追加する方法へ進んでください。