実装

パスキーの自動入力(conditional UI)の実装:入力欄の候補にパスキーを出す

8 分で読めます

この記事の要点

  • conditional UI は、ログイン画面を開いた時点で WebAuthn の呼び出しを待たせておき、ユーザー名の入力欄の候補にパスキーを並べる仕組みです。
  • 入力欄に autocomplete="username webauthn" を付け、対応を確かめてから mediation 'conditional' で navigator.credentials.get() を呼びます。
  • パスキーを持たない利用者はそのままパスワードで進めるため、既存のログイン画面を変えずに足せます。

conditional UI (conditional mediation) は、ログイン画面のユーザー名の入力欄に、パスワードの候補と並べてパスキーを出す仕組みです。入力欄に autocomplete="username webauthn" を付け、画面を開いたときに mediation: 'conditional' を指定して navigator.credentials.get() を呼んでおくと、利用者が入力欄を選んだときに候補が出ます。パスキーを選べばそのままログインが終わり、パスワードを選んだ人は今までどおり進めます。既存のログイン画面の見た目を変えずに足せるのが利点です。

何が起きるのか

通常の「パスキーでログイン」ボタンは、押した時点でブラウザの認証ダイアログを出します。パスキーを持っていない利用者にもダイアログが出るので、ボタンを押してから「この端末にはパスキーがありません」と分かることになります。

conditional mediation は、この順序を変えます。W3C の WebAuthn の仕様では、mediation が "conditional" のとき、目立つモーダルの UI は、資格情報が見つからない限り出さないとされています。画面の表示と同時に呼び出しておいても何も出ず、利用者が入力欄を選んだときに、その端末で使えるパスキーがあれば自動入力の候補として出ます。

ボタンでのログイン 自動入力 (conditional UI)
呼び出すとき ボタンを押したとき 画面を開いたとき
ダイアログ すぐに出る 利用者が候補を選ぶまで出ない
パスキーが無い利用者 「見つからない」表示になる 何も起きず、パスワードで進む
待ち時間 利用者が操作するまで 候補を選ぶまで (画面を離れるまで続くことがある)

実装の手順

1. 入力欄に autocomplete を付ける

html
<input type="text" name="username" autocomplete="username webauthn" />

web.dev の手引きは、webauthn を値の最後のトークンにするよう書いています。

2. 対応を確かめる

js
if (window.PublicKeyCredential &&
    PublicKeyCredential.isConditionalMediationAvailable &&
    await PublicKeyCredential.isConditionalMediationAvailable()) {
  startConditionalLogin();
}

PublicKeyCredential.isConditionalMediationAvailable() は、conditional mediation が使えるかを Promise で返します。web.dev の手引きは、より新しい PublicKeyCredential.getClientCapabilities() での確かめ方も紹介しています。対応していない環境では、ボタンとパスワードのログインだけを出します。

3. challenge を取り、待ち受けを始める

js
async function startConditionalLogin() {
  const res = await fetch('/api/passkey/login-options', { method: 'POST' });
  const json = await res.json();
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json.options);

  abortController = new AbortController();
  const credential = await navigator.credentials.get({
    publicKey,
    mediation: 'conditional',
    signal: abortController.signal,
  });
  await fetch('/api/passkey/login-verify', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(credential.toJSON()),
  });
}

サーバーは利用者を決めずに challenge を発行します。allowCredentials は空にします。どの利用者がログインしようとしているかは、まだ分からないためです。返ってきた応答の credential ID (と userHandle) から利用者を引き、署名を検証します。

4. 中断できるようにする

conditional の呼び出しは、利用者が候補を選ぶまで終わりません。次のときは AbortController で止めます。

  • 利用者が「パスキーでログイン」ボタンを押した (ボタンのための navigator.credentials.get() を呼ぶ前に止める)
  • SPA で別の画面へ移った
  • パスワードでログインが済んだ

web.dev の手引きも、利用者がパスワードを選んだときや画面を離れるときのために AbortController を使うよう書いています。

つまずきやすい点

discoverable credential でないと候補に出ない

自動入力の呼び出しでは allowCredentials を空にするため、候補になるのは、利用者名を知らないまま端末が見つけられるパスキー (discoverable credential) です。WebAuthn の仕様も、discoverable credential を「RP が credential ID を渡さない認証で使える資格情報」と定義しています。登録のときに residentKey を discouraged にしていた鍵は出ないことがあります。自動入力を主な入口にするなら、登録で residentKey: 'required' を求めます。

入力欄が無い・遅れて描画される

autocomplete="username webauthn" の入力欄が無い画面で呼ぶと、候補を出す場所がありません。フレームワークで入力欄を後から描画する場合は、描画の後に呼び出します。

challenge の期限

自動入力の待ち受けは、利用者が画面を開いたまま長く置くことがあります。サーバーが challenge に有効期限を付けている場合、候補を選んだ時点で期限が切れていることがあります。検証が期限切れで失敗したら、新しい challenge で待ち受けをやり直します。

ボタンも残す

自動入力は、利用者が入力欄を選ばないと見えません。目立つ「パスキーでログイン」ボタンと併用すると、パスキーを持つ利用者が迷いません。

パスワードの流れを壊さない

パスワードを選んだ場合、conditional の Promise は解決しないまま、通常のパスワードのログインが続きます。パスワードで送信する処理を、パスキーの待ち受けに依存させないようにします。

SealGate で実装する場合

SealGate のブラウザ SDK は、自動入力の待ち受けと中断を 1 行ずつで書けます。

html
<input name="username" autocomplete="username webauthn" />
js
import { SealGate } from 'https://sealgate.jp/sdk/v1.js';

const sg = new SealGate({
  optionsUrl: '/api/passkey/options',
  verifyUrl: '/api/passkey/verify',
});

if (await SealGate.conditionalMediationAvailable()) {
  sg.login({}, { mediation: 'conditional' })
    .then(onSignedIn)
    .catch((e) => { if (e.code !== 'aborted') console.error(e); });
}

// ボタンでのログインに切り替えるときや、SPA で画面を離れるとき
SealGate.abort();
  • 開発者のサーバーは、external_id を付けずに POST /v1/login/options を呼びます。利用者が候補を選ぶと、SDK が認証器の応答を verifyUrl に送ります。開発者のサーバーがそれを POST /v1/login/verify に中継すると、external_id が返ります。
  • 中断された呼び出しは SealGateError の code: 'aborted' で失敗します。自動入力に対応していない環境や、対応する入力欄が無い場合は unsupported です。
  • challenge は 5 分で失効し、1 回しか使えません。待ち受けの間に期限が切れると、verify が 410 (challenge_expired) を返すので、sg.login({}, { mediation: 'conditional' }) を呼び直します。
  • 自動入力だけで運用する場合は、環境の設定で resident_key を required にします (既定は preferred)。API キーからは PATCH /v1/apps/{app_id} で変えられます。

API の詳細は ドキュメント をご覧ください。

よくある質問

自動入力を入れたら、パスキーのボタンは要りませんか?

残すことを勧めます。自動入力は入力欄を選ぶまで見えないので、ボタンがあるとパスキーを持つ利用者が入口を見つけやすくなります。

パスキーを持っていない利用者に何か表示されますか?

何も表示されません。候補にパスキーが出ないだけで、利用者は今までどおりパスワードでログインします。

自動入力の候補に出るのはどのパスキーですか?

その端末 (またはパスワードマネージャー) にある、このサイトの RP ID 向けの discoverable credential です。利用者名を指定しない呼び出しなので、端末が自分で見つけられるパスキーだけが候補になります。

出典

関連する記事

ブログの一覧へ