サーバーサイドのパスキー登録

概要

パスキーの登録に関する主な手順の概要は次のとおりです。

パスキー登録フロー

  • パスキーを作成するためのオプションを定義します。これをクライアントに送信して、パスキー作成呼び出し(ウェブでは WebAuthn API 呼び出し navigator.credentials.create、Android では credentialManager.createCredential)に渡せるようにします。ユーザーがパスキーの作成を確認すると、パスキー作成の呼び出しは解決され、認証情報 PublicKeyCredential が返されます。
  • 認証情報を確認し、サーバーに保存します。

以降のセクションでは、各ステップの詳細を説明します。

認証情報作成オプションを作成する

サーバーで必要な最初のステップは、PublicKeyCredentialCreationOptions オブジェクトの作成です。

そのためには、FIDO のサーバーサイド ライブラリを使用します。通常は、これらのオプションを作成できるユーティリティ関数が用意されています。SimpleWebAuthn では generateRegistrationOptions などを使用できます。

PublicKeyCredentialCreationOptions には、ユーザーに関する情報、RP に関する情報、作成する認証情報のプロパティの構成など、パスキーの作成に必要な情報がすべて含まれている必要があります。これらをすべて定義したら、必要に応じて、PublicKeyCredentialCreationOptions オブジェクトの作成を担当する FIDO サーバーサイド ライブラリの関数に渡します。

PublicKeyCredentialCreationOptions のフィールドの一部は定数にできます。それ以外はサーバーで動的に定義する必要があります。

  • rpId: サーバーで RP ID を入力するには、ウェブ アプリケーションのホスト名を提供するサーバーサイド関数または変数(example.com など)を使用します。
  • user.nameuser.displayName: これらのフィールドに入力するには、ログインしているユーザーのセッション情報(ユーザーが登録時にパスキーを作成している場合は新しいユーザー アカウント情報)を使用します。通常、user.name はメールアドレスで、RP に対して一意です。user.displayName はユーザー フレンドリーな名前です。なお、すべてのプラットフォームで displayName が使用されるわけではありません。
  • user.id: アカウント作成時に生成される、ランダムな一意の文字列。編集可能なユーザー名とは異なり、永続的な名前にする必要があります。ユーザー ID はアカウントを識別しますが、個人を特定できる情報(PII)を含めないでください。システムにすでにユーザー ID が入っている可能性はありますが、必要に応じて、パスキー専用のユーザー ID を作成して、個人情報が含まれないようにします。
  • excludeCredentials: パスキー プロバイダのパスキーの重複を防ぐための既存の認証情報 ID のリスト。このフィールドにデータを入力するには、データベースの既存の認証情報で、このユーザーの情報を検索します。詳しくは、パスキーがすでに存在する場合は新しいパスキーを作成できないようにするをご覧ください。
  • challenge: 認証情報の登録では、証明書を使用しない限り、チャレンジは関係ありません。証明書とは、パスキー プロバイダの ID とそれが発行するデータを確認する高度な手法です。ただし、構成証明を使用しない場合でも、チャレンジは必須フィールドです。その場合はわかりやすくするために、このチャレンジを 1 つの 0 に設定できます。認証用の安全なチャレンジを作成する手順については、サーバーサイド パスキー認証をご覧ください。

エンコードとデコード

サーバーによって送信された PublicKeyCredentialCreationOptions
サーバーから送信された PublicKeyCredentialCreationOptionsPublicKeyCredentialCreationOptions を HTTPS 経由で配信できるように、challengeuser.idexcludeCredentials.credentials をサーバーサイドで base64URL にエンコードする必要があります。

PublicKeyCredentialCreationOptions には ArrayBuffer のフィールドが含まれているため、JSON.stringify() ではサポートされません。つまり現時点では、HTTPS 経由で PublicKeyCredentialCreationOptions を配信するには、一部のフィールドをサーバー上で base64URL を使用して手動でエンコードしてから、クライアント上でデコードする必要があります。

  • サーバーでは、通常、エンコードとデコードが FIDO サーバーサイド ライブラリによって処理されます。
  • 現時点では、クライアントでエンコードとデコードを手動で行う必要があります。今後、これはより簡単になり、オプションを JSON として PublicKeyCredentialCreationOptions に変換するメソッドが利用可能になります。Chrome での実装のステータスを確認します。

サンプルコード: 認証情報作成オプションを作成する

この例では、SimpleWebAuthn ライブラリを使用しています。ここでは、公開鍵認証情報オプションの作成を generateRegistrationOptions 関数に渡します。

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

router.post('/registerRequest', csrfCheck, sessionCheck, async (req, res) => {
  const { user } = res.locals;
  // Ensure you nest verification function calls in try/catch blocks.
  // If something fails, throw an error with a descriptive error message.
  // Return that message with an appropriate error code to the client.
  try {
    // `excludeCredentials` prevents users from re-registering existing
    // credentials for a given passkey provider
    const excludeCredentials = [];
    const credentials = Credentials.findByUserId(user.id);
    if (credentials.length > 0) {
      for (const cred of credentials) {
        excludeCredentials.push({
          id: isoBase64URL.toBuffer(cred.id),
          type: 'public-key',
          transports: cred.transports,
        });
      }
    }

    // Generate registration options for WebAuthn create
    const options = generateRegistrationOptions({
      rpName: process.env.RP_NAME,
      rpID: process.env.HOSTNAME,
      userID: user.id,
      userName: user.username,
      userDisplayName: user.displayName || '',
      attestationType: 'none',
      excludeCredentials,
      authenticatorSelection: {
        authenticatorAttachment: 'platform',
        requireResidentKey: true
      },
    });

    // Keep the challenge in the session
    req.session.challenge = options.challenge;

    return res.json(options);
  } catch (e) {
    console.error(e);
    return res.status(400).send({ error: e.message });
  }
});

公開鍵を保存する

サーバーによって送信された PublicKeyCredentialCreationOptions
navigator.credentials.createPublicKeyCredential オブジェクトを返します。

クライアントで navigator.credentials.create が正常に解決された場合、パスキーが正常に作成されたことを意味します。PublicKeyCredential オブジェクトが返されます。

PublicKeyCredential オブジェクトには、クライアントのパスキー作成指示に対するパスキー プロバイダのレスポンスを表す AuthenticatorAttestationResponse オブジェクトが含まれます。これには、後でユーザーを認証するために RP として必要となる新しい認証情報に関する情報が含まれています。AuthenticatorAttestationResponse について詳しくは、付録: AuthenticatorAttestationResponse をご覧ください。

PublicKeyCredential オブジェクトをサーバーに送信します。届きましたら、内容を確認します。

この確認手順を FIDO サーバーサイド ライブラリに提出します。通常は、この目的のためのユーティリティ機能が提供されています。SimpleWebAuthn では verifyRegistrationResponse などを使用できます。内部の仕組みについては、付録: 登録応答の検証をご覧ください。

検証が成功したら、認証情報をデータベースに保存し、ユーザーが後でその認証情報に関連付けられたパスキーを使用して認証できるようにします。

パスキーに関連付けられた公開鍵認証情報専用のテーブルを使用します。ユーザーが設定できるパスワードは 1 つのみですが、複数のパスキーを設定できます。たとえば、Apple iCloud キーチェーンで同期されたパスキーと、Google パスワード マネージャーを介して同期されるパスキーを使用できます。

認証情報の保存に使用できるスキーマの例を以下に示します。

パスキーのデータベース スキーマ

  • Users テーブル:
    • user_id: プライマリ ユーザー ID。ユーザーのランダムかつ一意で永続的な ID。これを Users テーブルの主キーとして使用します。
    • username。ユーザー定義のユーザー名。編集可能です。
    • passkey_user_id: 個人情報(PII)を含まないパスキー固有のユーザー ID。登録オプションuser.id で表されます。後でユーザーが認証を試みると、認証システムは userHandle の認証レスポンスでこの passkey_user_id を利用できるようにします。passkey_user_id を主キーとして設定しないことをおすすめします。主キーは広く使用されているため、システムで事実上の個人情報(PII)になる傾向があります。
  • [公開鍵認証情報] テーブル:
    • id: 認証情報 ID。これを [公開鍵認証情報] テーブルの主キーとして使用します。
    • public_key: 認証情報の公開鍵。
    • passkey_user_id: これを外部キーとして使用して、Users テーブルとのリンクを確立します。
    • backed_up: パスキー プロバイダによって同期されている場合、パスキーはバックアップされます。バックアップの状態を保存すると、backed_up のパスキーを保持するユーザーのパスワードを今後削除することを検討する際に有用です。パスキーがバックアップされているかどうかを確認するには、authenticatorData のフラグを調べるか、この情報に簡単にアクセスするために通常利用できる FIDO サーバーサイド ライブラリ機能を使用します。バックアップの利用資格を保存すると、ユーザーからの問い合わせに対処するのに役立ちます。
    • name: 認証情報の表示名(省略可)。この表示名を使用すると、ユーザーが認証情報にカスタム名を付けられます。
    • transports: transports の配列。トランスポートの保存は、認証のユーザー エクスペリエンスに役立ちます。トランスポートを利用できる場合、ブラウザは適切に動作し、パスキー プロバイダがクライアントとの通信に使用するトランスポートと一致する UI を表示できます(特に allowCredentials が空でない再認証のユースケース)。

パスキーのプロバイダ、認証情報の作成時刻、最終使用時刻など、その他の情報も、ユーザー エクスペリエンスのために保存するのに役立ちます。詳しくは、パスキーのユーザー インターフェース デザインをご覧ください。

コード例: 認証情報を保存する

この例では、SimpleWebAuthn ライブラリを使用しています。 ここでは、登録レスポンスの検証を verifyRegistrationResponse 関数に渡します。

import { isoBase64URL } from '@simplewebauthn/server/helpers';


router.post('/registerResponse', csrfCheck, sessionCheck, async (req, res) => {
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get('User-Agent'));
  const expectedRPID = process.env.HOSTNAME;
  const response = req.body;
  // This sample code is for registering a passkey for an existing,
  // signed-in user

  // Ensure you nest verification function calls in try/catch blocks.
  // If something fails, throw an error with a descriptive error message.
  // Return that message with an appropriate error code to the client.
  try {
    // Verify the credential
    const { verified, registrationInfo } = await verifyRegistrationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      requireUserVerification: false,
    });

    if (!verified) {
      throw new Error('Verification failed.');
    }

    const { credentialPublicKey, credentialID } = registrationInfo;

    // Existing, signed-in user
    const { user } = res.locals;
    
    // Save the credential
    await Credentials.update({
      id: base64CredentialID,
      publicKey: base64PublicKey,
      // Optional: set the platform as a default name for the credential
      // (example: "Pixel 7")
      name: req.useragent.platform, 
      transports: response.response.transports,
      passkey_user_id: user.passkey_user_id,
      backed_up: registrationInfo.credentialBackedUp
    });

    // Kill the challenge for this session
    delete req.session.challenge;

    return res.json(user);
  } catch (e) {
    delete req.session.challenge;

    console.error(e);
    return res.status(400).send({ error: e.message });
  }
});

付録: AuthenticatorAttestationResponse

AuthenticatorAttestationResponse には、次の 2 つの重要なオブジェクトが含まれています。

  • response.clientDataJSONクライアント データの JSON バージョンで、ウェブ上で表示されるデータはブラウザに表示されます。これには、RP 生成元、チャレンジ、androidPackageName(クライアントが Android アプリの場合)が含まれます。RP として clientDataJSON を読み取ることで、create リクエスト時にブラウザが参照した情報にアクセスできます。
  • response.attestationObject には、次の 2 つの情報が含まれます。
    • attestationStatement。これは構成証明を使用しない限り関係ありません。
    • authenticatorData は、パスキー プロバイダから見えるデータです。RP として authenticatorData を読み取ることで、パスキー プロバイダによって参照され、create リクエストの時点で返されたデータにアクセスできます。

authenticatorData には、新しく作成されたパスキーに関連付けられた公開鍵認証情報に関する重要な情報が含まれます。

  • 公開鍵認証情報自体と、その一意の認証情報 ID。
  • 認証情報に関連付けられている RP ID。
  • パスキーが作成されたときのユーザー ステータスを説明するフラグ。ユーザーが実際に存在しているかどうか、ユーザーが正常に確認されたかどうかなどを示します(userVerification を参照)。
  • AAGUID: パスキーのプロバイダを識別します。パスキー プロバイダを表示すると、ユーザーがサービス用に複数のパスキー プロバイダでパスキーを登録している場合に特に役立ちます。

authenticatorDataattestationObject 内にネストされていますが、証明書を使用するかどうかにかかわらず、そこに含まれる情報はパスキーの実装に必要です。authenticatorData はエンコードされ、バイナリ形式でエンコードされたフィールドを含みます。通常、サーバー側のライブラリは解析とデコードを処理します。サーバーサイドのライブラリを使用しない場合は、getAuthenticatorData() のクライアントサイドを活用して、サーバーサイドでの解析とデコードの作業を軽減することを検討してください。

付録: 登録応答の検証

登録レスポンスの検証は、内部で次のチェックで構成されます。

  • RP ID がサイトと一致していることを確認します。
  • リクエストの送信元が、サイト(メインサイトの URL、Android アプリ)で想定される送信元であることを確認します。
  • ユーザー確認が必要な場合は、ユーザー確認フラグ authenticatorData.uvtrue であることを確認してください。パスキーではユーザーのプレゼンスが常に必須であるため、ユーザー プレゼンス フラグ authenticatorData.uptrue であることを確認します。
  • 提示した課題をお客様が提供できたことを確認する。構成証明を使用しない場合、このチェックは重要ではありません。ただし、このチェックの実装はベスト プラクティスです。将来構成証明を使用することに決めた場合に、コードを確実に準備できます。
  • どのユーザーの認証情報 ID もまだ登録されていないことを確認します。
  • パスキー プロバイダが認証情報の作成に使用するアルゴリズムが、リストしたアルゴリズムであることを確認します(publicKeyCredentialCreationOptions.pubKeyCredParams の各 alg フィールドに指定。通常はサーバーサイド ライブラリ内で定義され、ユーザーには表示されません)。これにより、許可することを選択したアルゴリズムのみにユーザーが登録できるようになります。

詳しくは、SimpleWebAuthn の verifyRegistrationResponse のソースコードを確認するか、仕様にある検証の一覧をご覧ください。

次回

サーバーサイド パスキー認証