実装
パスキーの自動入力(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 を付ける
<input type="text" name="username" autocomplete="username webauthn" />web.dev の手引きは、webauthn を値の最後のトークンにするよう書いています。
2. 対応を確かめる
if (window.PublicKeyCredential &&
PublicKeyCredential.isConditionalMediationAvailable &&
await PublicKeyCredential.isConditionalMediationAvailable()) {
startConditionalLogin();
}PublicKeyCredential.isConditionalMediationAvailable() は、conditional mediation が使えるかを Promise で返します。web.dev の手引きは、より新しい PublicKeyCredential.getClientCapabilities() での確かめ方も紹介しています。対応していない環境では、ボタンとパスワードのログインだけを出します。
3. challenge を取り、待ち受けを始める
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 行ずつで書けます。
<input name="username" autocomplete="username webauthn" />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 です。利用者名を指定しない呼び出しなので、端末が自分で見つけられるパスキーだけが候補になります。