zukucode
主にWEB関連の情報を技術メモとして発信しています。

React パスキー認証を実装する④ 登録・ログイン画面をつなぐ

Reactからパスキーの登録・ログインAPIを呼び出します。

フロントエンドの役割は、サーバーが発行した設定をWebAuthnへ渡し、ブラウザーが返した結果をサーバーへ送ることです。登録先の利用者やRP IDを、画面側で独自に組み立て直さないようにします。

  1. 全体の流れと保存データ
  2. 登録APIを作る
  3. ログインAPIを作る
  4. 登録・ログイン画面をつなぐ(この記事)

前回までのログイン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

まず、通信とブラウザーの認証操作をpasskey.tsにまとめます。

passkey.ts
function 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 : '処理に失敗しました。';
}

登録は、次の順番です。

  1. CSRFトークンを取得する
  2. register/optionsで登録設定を取得する
  3. JSONを変換してnavigator.credentials.createへ渡す
  4. 結果をJSONへ変換してregister/finishへ送る

ログインも同じ形で、navigator.credentials.getを使います。

publicKeyの中身はサーバーの設定をそのまま使います。たとえばrp.idをwindow.location.hostnameから作り直すと、サーバー側で決めたRP IDと食い違う可能性があります。許可するアルゴリズムやユーザー検証の条件も、画面側に別の定義を持たせません。

完了APIは204 No Contentを返すため、成功時にresponse.json()を呼びません。空のレスポンスをJSONとして読もうとすると、認証に成功していても画面側でエラーになります。

ボタンから呼び出す

コンポーネントでは、実行中の状態と結果のメッセージを管理します。

PasskeyButton.tsx
import { 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の設定を確認します。利用者へは内部設定を並べず、開発者がブラウザーのコンソールやサーバーのログで調べられるようにします。

また、ネットワークエラーと認証の拒否は別です。サーバーが登録を保存したあとにレスポンスだけ届かない場合もあるため、通信失敗を「絶対に登録されていない」と読み替えないようにします。実際の設定画面では、登録済みパスキーの一覧を再取得して確認できると扱いやすくなります。

登録からログインまで通して確認する

まず、次の順番で実機確認します。

  1. 既存のログイン方法で、自分のアカウントにログインする
  2. 設定画面でパスキーを登録する
  3. サーバーにCredential ID、公開鍵、利用者IDが保存されたことを確認する
  4. アプリの既存のログアウト処理を実行する
  5. 「パスキーでログイン」を押し、登録したアカウントを選ぶ
  6. 認証が必要なAPIを呼び、正しい利用者としてログインできたことを確認する

Cookie認証では、ログインAPIの204だけを見るのではなく、次のAPI呼び出しでも認証状態が引き継がれることを確認します。

次に、失敗させる条件も確認します。

確認する操作期待する結果
未ログインで登録開始APIを呼ぶ401で拒否される
CSRFヘッダーなしでPOSTする拒否される
認証画面をキャンセルする完了表示にならず、ボタンを再度押せる
同じ完了リクエストを2回送る2回目は拒否される
開始から5分以上たって完了APIを呼ぶ期限切れとして拒否される
別ブラウザーへflowIdと認証結果をコピーする操作Cookieが異なるため拒否される
登録用flowIdをログイン完了APIへ渡す用途の不一致で拒否される
登録開始後に別の利用者へログインし直す元の利用者への登録は成功しない
署名の一部を変更して送る検証に失敗し、認証Cookieは発行されない
削除済みのパスキーや無効な会員でログインするログインできない

サーバーの拒否条件を確認するときは、ブラウザーの認証画面が先に処理を止める場合と区別します。期限切れや用途違いなどは、テスト用クライアントでAPIを直接呼ぶと確かめやすくなります。別ブラウザーのテストでは、そのブラウザーで取得したCSRFトークンを使い、CSRFの失敗だけでテストを終えないようにします。

自動化する場合は、仮想認証器を利用できます。ただし、同期型パスキーの選択画面や端末間の操作は実機でも確認します。コンパイルや型チェックだけでは、OSやブラウザーをまたぐ一連の認証が動くことまでは保証できません。

運用に必要な機能を追加する

この連載で実装したのは、既存アカウントへのパスキー登録と、登録済みパスキーでのログインです。公開する前に、利用者が自分で管理できる画面も用意します。

  • 登録済みパスキーの一覧と、利用者が付ける名前
  • 不要になったパスキーの削除
  • 端末をなくした場合の、本人確認を伴う復旧方法
  • 最後のログイン手段を削除するときの確認

パスキーを複数登録できるようにしておくと、1台の端末が使えなくなった場合にも別の手段でログインできます。別端末への追加手順を作る場合は、パスキーを別の端末に安全に追加する方法へ進んでください。


関連記事