ユースケース — パスワードレス passkey
passkey は、パスワードの後ろに置く第 2 要素にも、ログインそのものにもなります。このページが扱うのは後者です。ユーザがブラウザで credential を選ぶと、credential がアカウントを特定し、パスワードは入力しません。登録済みのデバイスを持たないユーザもサインインできるように、パスワードと並べて提供します。
パスワードから passkey へ段階的に移行したい場合に使います。パスワードの後の追加要素として passkey が欲しいだけなら、op.PrimaryPasskey をルールに置きます。MFA / ステップアップを参照してください。
このページで触れる仕様
- WebAuthn Level 3 — credential の ceremony、user verification、discoverable credential
- RFC 8176 — Authentication Method Reference Values(
amr、hwk) - NIST SP 800-63B — Authenticator Assurance Levels(AAL)
ソース:
examples/29-passkeyは、example 自身が持つページでデバイスを登録し、その passkey で OP にサインインします。
ログインフロー
LoginFlow.Primary に、パスワードのステップと passkey のステップを持つ op.StepOneOf を渡します。
passkeys := op.PrimaryPasskey{
Store: st.Passkeys(),
RPID: "id.example.com",
RPDisplayName: "Example Identity",
RPOrigins: []string{"https://id.example.com"},
RequireUserVerification: true,
}
flow := op.LoginFlow{
Primary: op.StepOneOf{Steps: []op.Step{
op.PrimaryPassword{Store: st.UserPasswords()},
passkeys,
}},
}
provider, err := op.New(
/* ... */
op.WithLoginFlow(flow),
op.WithUI(op.SPAUI{LoginMount: "/login", StaticDir: "./web"}),
)Primary の時点では OP はまだユーザを特定していないため、ユーザがどの認証要素を登録済みか確認できません。そこで Steps のすべてを宣言順に、認証要素選択プロンプト(op/interaction.PromptTypeChooseFactor、"auth.choose_factor")で提示します。UI は選ばれたステップの kind を factor フィールド(interaction.FactorChoiceField)で送り返します。kind は password と passkey です。提示した kind 以外の選択は、古い state_ref と同じ扱いで拒否されます。認証要素を選んだ後は、その試行は選んだ要素のまま進みます。ログインをやり直すと、選択からやり直せます。
Primary: passkeys のように passkey 単独を置いても、passwordless の ceremony は動きます。ただし選択の画面は出ません。登録済みのデバイスを持たないユーザはサインインできなくなるため、全ユーザの登録が済むまではパスワードを並べておいてください。
Primary に置いた passkey ステップの動き
ルールに置いた op.PrimaryPasskey は、前のステップが特定したユーザの credential に対して assertion を検証します。Primary または、そこに置いた StepOneOf の中では、discoverable credential を使う passwordless の ceremony になります。
- プロンプトには新しい challenge と、空の
allow_credentialsが入ります。ブラウザは、その RP の resident credential から選ばせます。 - 選ばれた credential の保存レコードが subject を指します。
- assertion の user handle は、その subject と一致しなければなりません。
未知の credential と、credential の subject に一致しない user handle は、どちらも同じ新しい challenge で応答します。どの credential が存在するかは、応答から分かりません。
user verification は必須
passkey が単独で置かれると、それがログインの唯一の要素になります。そのため op.New は、Primary または、そこに置いた StepOneOf の中の PrimaryPasskey に RequireUserVerification: true がなければ拒否します。authenticator は PIN または生体の操作を行う必要があり、UV フラグが立っていない assertion は受理されません。
この操作があるので、credential 1 つで 2 要素(鍵の所持と、ユーザが知っている・ユーザ自身であること)になります。このステップを通ったログインは AAL2 に達し、amr は RFC 8176 の単一要素ハードウェア鍵の値 ["hwk"] になります。AAL2 を acr_values で要求する RP は、これで満たされます。user verification は保存済み credential の値ではなく、assertion ごとのフラグから読み取ります。要求しない passkey ステップ(別の要素の後ろのルールに置いたものなど)では、presence-only の assertion は 1 要素として扱われ、amr は swk、AAL1 で、AAL2 には届きません。
登録でも同じ設定が効きます。op/passkeykit と共有する 1 つの op.PrimaryPasskey 値に RequireUserVerification を持たせておくと、登録時に受理された credential がログインで使えます。
Primary に置く StepOneOf のそのほかの規則です。
- すべてのステップが単独でユーザを特定できる必要があります。
op.PrimaryPassword、RequireUserVerificationを設定したop.PrimaryPasskey、CountFailuresを設定していないop.ExternalStepが対象です。TOTP、メール OTP、リカバリコードのステップは、前のステップが特定したユーザを必要とするため、ここでは拒否されます。 IfNoneEnrolledは零値のop.UnenrolledDenyのままにします。登録状況を確認する subject がまだないため、op.UnenrolledSkipは拒否されます。StepOneOfは入れ子にできません。同じ kind をフロー内で 2 回使うこともできません。
対話 UI は WebAuthn を実行できること
ブラウザの navigator.credentials.get() はスクリプトで、その結果の assertion はプロンプトへの返信として OP に戻ります。組み込みの HTML driver はクライアント側のスクリプトを出力しないため、このステップを完了できるユーザがいません。op.New はこの組み合わせを、WithUI、SPAUI、DriverUI に触れたエラーで拒否します。
ceremony を実行して認証要素選択プロンプトも表示できるフロントエンドを op.WithUI(op.SPAUI{...}) で使うか、自前の interaction.Driver を op.DriverUI で使います。ライブラリ外で作った driver は、対応できるものとみなされます。番号付き example が共有する SPA バンドルは両方のプロンプトを処理するので、example 29 は専用の UI を持ちません。認証要素のラベルは i18n キー auth.factor.password と auth.factor.passkey から引きます。カタログにない kind は、kind の文字列がそのまま表示されます。プロンプトのプロトコルはSPA / 対話画面のカスタマイズを参照してください。
passkey に必要なのは IP ではなくドメイン
WebAuthn の Relying Party ID はドメイン名でなければならず、ブラウザは IP リテラルを拒否します。ローカル開発では、issuer を localhost にし、op.WithAllowLocalhostIssuerForDev() を設定します。本番の RP は https の背後に置いてください。
登録は組み込み側のページ
OP は組み込み側のアカウント管理を見られないので、「passkey を追加する」画面は提供できません。登録の ceremony は、組み込み側が書くページと 2 つのハンドラで、op/passkeykit を使って実行します。
registrar, err := passkeykit.New(passkeys) // ログインフローに渡したものと同じ値
opts, session, err := registrar.Begin(ctx, passkeykit.User{
Subject: subject, Name: username, DisplayName: displayName,
})
// opts はブラウザへ送り、session はサーバ側に保持する。
record, err := registrar.Register(ctx, session, user, responseBytes)組み込み側が持つのは次の 4 点です。
- 先にユーザを認証する:
passkeykitは、ハンドラが指定した subject をそのまま登録します。セッションなしで到達できる登録エンドポイントがあると、攻撃者が他人のアカウントに authenticator を追加できます。直近のサインインを要求し、その強度は追加する要素以上にしてください。 - session を運ぶ: challenge と完了呼び出しは 2 つのリクエストに分かれて届くため、その間
Sessionの完全性を守る必要があります。example は、ランダムな cookie 値をキーにサーバ側のマップへ保持しています。Sessionは 1 回しか使えません。完了呼び出しの後は、成否にかかわらず破棄します。 - RP の識別を共有する:
passkeykit.Newは、ログインフローが使うop.PrimaryPasskeyと同じ値を受け取ります。credential が結び付く RP ID と origin の一覧が、検証側とずれることはありません。RPIDを変えると、登録済みのすべての credential が無効になります。 - ストアを永続化する: 再起動で消える passkey レコードは、ユーザに再登録を強いるデバイスになります。インメモリのストアは example 用です。
初回のログインでは選べる passkey がないため、ユーザはパスワードでサインインし、登録ページでデバイスを登録して、次回から passkey を選びます。
passkey ログインの claim
/userinfo と ID トークンの claim は user store から読みます。WithUserStore を指定しない場合、OP は Primary、またはそこに置いた StepOneOf の中にある PrimaryPassword の Store から読みます。op.WithStore に渡した store が参照されることはないため、PrimaryPassword を持たない passkey だけのフローでは、credential が指す subject のユーザレコードを返す store を op.WithUserStore に渡す必要があります。渡さないと op.New は起動を拒否します。同梱アダプタなら op.WithUserStore(st.Users()) で足ります。
クローン検知と attestation
CloneDetectionHandler は、authenticator が報告した署名カウンタが単調に増えなかったときに呼ばれます。デバイスが複製された場合の見え方です。OP はそのログインを失敗させます。credential を無効にしてユーザに知らせる処理は、このハンドラに書きます。AAGUIDAllowlist は登録を許可する authenticator のモデルを絞るもので、AttestationRoots が必要です。MFA / ステップアップの警告を参照してください。
次へ
- MFA / ステップアップ: 第 2 要素としての passkey と、そのほかのステップ。
- SPA / 対話画面のカスタマイズ: UI が実装するプロンプトのプロトコル。
- ここから始める: 目的からつぎのページを選びます。