English version of this article is availablehere
こんにちは、CTO室 IDサービス開発部のyamato(@8ma10s)です。マネーフォワード IDという、当社サービス向けのIdPを開発しています。
今回このマネーフォワード IDにおいて、パスワードを使わずに、生体認証などを利用してログインできる「パスワードレスログイン」という機能をリリースしました。
また、今回のリリースでは、既にいくつかの他社サービスで導入されているような通常のパスワードレスログインUIではなく、「Passkey autofill」という、ブラウザの自動補完を利用する新しいタイプのパスワードレスログインUI を(恐らく日本のサービスで初めて。エンドユーザーの目に触れるサービスという意味では、おそらく世界でも初めて)導入しています。
私達がどういった過程で、どのような課題を解決するためにPasskey autofillの導入を決めリリースに至ったか、また、リリース後の今後の課題についてご紹介します。
我々がパスワードレス認証を導入したかった理由は色々ありますが、主に以下のようなものです。
将来的には「パスワードの完全無効化」なども視野に入れていますが、あくまで直近の目標としては「ユーザーが使いたいサービスにより早くアクセスできるようになればいいな」くらいの気持ちでパスワードレス認証の対応を始めました。
後述する課題などもあり、当時は対外的にアナウンスなどはしていなかったのですが、実はパスワードレス認証の実装そのものは2022年11月の時点でユーザー向けにひっそりとリリースしていました。
当時リリースした traditional WebAuthnの流れは以下のようなものでした。
「もうこの時点でパスキーでログインは完成しているのでは?」と思うかもしれません。が、この初期実装にはいくつか課題がありました。
Traditional WebAuthnの認証フローは、Emailを入力してログインボタンを押すと、いきなり認証プロンプトが出てきて認証を求められます。このように「Web UI上のボタンを押すといきなり顔認証や指紋認証を要求される」というUIは、今までのパスワードログインではあまり見なかったUIであり、パスワードからのシームレスな移行を阻害する要因になっていました。
WebAuthn仕様にもあるように、基本的にはWebAuthn RP2がブラウザから「サーバーで持っている情報に対応するパスキーが、そのデバイスに存在するか」を、credentials.get
を呼ぶ前に確認する手段はありません。
そのため、スマートフォンからパスキーを登録したアカウントに別のデバイスからログインしようとすると、対応するパスキーが存在しないのにもかかわらずcredentials.get
を呼んでしまうことになり、以下のようなプロンプトが出てきてしまいます。
ユーザーとしてはパスワード入力に進みたくて送信ボタンを押したのに、邪魔なプロンプトが出てきて、それをキャンセルすることで初めてパスワード入力画面に進めるという非常に分かりにくいUXになってしまいました。
マネーフォワード IDのようないわゆる「WebAuthn RP」としては、以下の2点がパスキー導入における必須要件でした。
しかし、いわゆる "Traditional WebAuthn" に前述したような課題が存在し、移行がシームレスでなかったり、パスワードを利用しているユーザーのUXを損なってしまうことは、初期実装を進めている時点で既に分かっていたので、初期実装のリリース時に大々的に告知をして利用ユーザー数を増やすことはしませんでした。
我々がこれらを解決する次のステップとして置いたのが、Passkey autofillの実装でした。
WebAuthn界隈では"conditional mediation"や "conditional UI" と呼ばれている技術です。
2022年5月にApple, Google, Microsoftの3社が「パスキーのサポートを拡大する」という共同声明を出しました。その後、その取り組みの一環として、パスキーをよりパスワードと似たUXの中で共存させる仕組みとしてWWDC22やGoogleのパスキーサポート発表の中でも触れられていたのが、このPasskey autofillです。
機能としては、「利用しているデバイスでログインに利用できるパスキーの候補を、ブラウザの自動補完機能を使って表示する」というものです。文字だと若干分かりづらいので、以下の動画をご覧ください。
Traditional WebAuthnで解決できていなかった課題を、Passkey autofillはいくつか解決できています。
前述の通り、Traditional WebAuthnにおいては、パスワードでログインする際と劇的に違うUIになってしまい、ユーザーの混乱を招く懸念がありました。
Passkey autofillを使うと、ログインの流れは以下のようになります。
この流れは、ブラウザなどに標準で搭載されているパスワードマネージャーでパスワードを補完する流れと全く同じです。
このように、パスワードログインとパスキーログインのUIをほぼ同一のものにすることで、ユーザーにそもそもパスキーとパスワードの違いを意識させないということが達成できます。
Traditional WebAuthnでは、「パスキーを登録したデバイスと異なるデバイスからログインを行おうとすると、パスキーではログイン不可能なのにもかかわらず認証プロンプトが表示されてしまう」という問題がありました。
Passkey autofillは、「デバイスで利用可能なパスキーしか表示されない」という特徴があります。なので、パスキーを利用できるデバイスではパスキーでログインしてもらい、利用できないデバイスでは自然にパスワード入力画面に誘導できるため、より理想に近いUXのパスキーログインを提供できます。
パスキーが解決する課題について一通りご紹介したところで、実際のコードの解説をしていきたいと思います。
Passkey autofillを利用していないtraditional WebAuthnについては、解説記事も既に沢山あるのでここでは触れません。
Passkey autofillのコード例を理解するにあたっては、
options
オブジェクト(認証時の設定を定義したオブジェクト)を取得するoptions
オブジェクトを引数にnavigator.credentials.get
を呼ぶget
メソッドの返り値をバックエンドで検証するという一連の流れを理解していれば大丈夫かと思います。
Traditional WebAuthnについて更に詳しく知りたい方は、以下のcodelabsなどが参考になるかと思います。https://developers.google.com/codelabs/webauthn-reauth
まず、全体の流れをシーケンス図で。
そしてこちらが、ページロード時に走るフロントエンドのコードサンプルです。※ ブラウザが特定のAPIを提供しているか、などのチェック部分は省略しています
const sendAuthenticatorResponseIfWebauthnAvailable=async()=>{try{// if browser is webauthn-compatible, fetch options from serverif(!(navigator.credentials&& navigator.credentials.create&& navigator.credentials.get&&window.PublicKeyCredential&&await PublicKeyCredential.isConditionalMediationAvailable())){returnfalse;}const optionsJSON=await backend.fetchWebauthnAssertionOptions();if(optionsJSON!=null){ options= webauthn.parseRequestOptionsFromJSON(optionsJSON);}else{returnnull;} options['mediation']='conditional';const response=await navigator.credentials.get(options);returnawait backend.postWebauthnAssertion(response.toJSON());// send the authenticator response to the backend}catch(e){console.log(e);returnnull;}};
このシーケンス図及びコードをベースに、順を追って解説していきます。
traditional WebAuthn では、ほとんどの処理が「Submitボタン押下時」に行われていましたが、 passkey autofillでは多くの処理が「ページのロード時」に行われます。
conditionalMediation
が利用できるかのチェックoptions
オブジェクトを取得credentials.get
の呼び出しですので、上記のコードは(Reactで言えばuseEffect
などを利用して)passkey autofillを利用したいページのロード時に走らせることになります。
まずは、フロントエンド側でWebAuthnが利用できるか、及び conditional mediation (= passkey autofill) が利用できるかのチェックを行います。
conditional mediationに関しては、PublicKey.isConditionalMediationAvailable()
という非同期メソッドがあるので、こちらの結果を確認すればOKです3。
// if browser is webauthn-compatible, fetch options from serverif(!(navigator.credentials&& navigator.credentials.create&& navigator.credentials.get&&window.PublicKeyCredential&&await PublicKeyCredential.isConditionalMediationAvailable())){returnfalse;}
options
オブジェクトを取得 (シーケンス図3,4)次にcredentials.get
にわたす optionsオブジェクトが必要です。options
オブジェクトに含まれるchallenge
はbackend側で生成されるべきなので、 backendにリクエストをします。
const optionsJSON=await backend.fetchWebauthnAssertionOptions();if(optionsJSON!=null){ options= webauthn.parseRequestOptionsFromJSON(optionsJSON);}else{returnnull;}
const fetchWebauthnAssertionOptions=async()=>{const response=awaitfetch(options_webauthn_assertion_path(),{ method:'POST', body:{}, headers:{ Accept:'application/json','Content-Type':'application/json',},});switch(response.status){case200:return response.json();default:returnnull;}};
結果、backendからは以下のようなallowCredentials
が空のoptions
オブジェクトが返ってきます。
{ "publicKey":{ "challenge": "DZ5BwnKQoeJK9RPrB0FEyjD7qnFLXUsEZ8lPKnK_jzU", "timeout":120000, "extensions":{}, "allowCredentials":[], "userVerification": "required"}}
passkey autofillのログインでは、サーバーが指定する server-side credentialではなく、クライアント側が記憶している client-side credential全てを候補として表示し、任意のユーザーでログインできるようにしたいので、allowCredentials
には空配列を渡します。
credentials.get
の呼び出し (シーケンス図 5,6)あとは取得したoptions
オブジェクトを引数としてcredentials.get
を呼ぶだけなのですが、このまま呼んでしまうと conditional mediationではなく、traditional WebAuthnで行っていた通常のcredentials.get
の呼び出しになってしまい、いきなり認証プロンプトが表示されてしまいます。ブラウザに conditional mediationを指示するために、options
オブジェクトにmediation: "conditional"
を追加します。
options['mediation']='conditional';const response=await navigator.credentials.get(options);
最終的にはこのようなoptions
をcredentials.get
に渡すことになります。
{ "publicKey":{ "challenge": "DZ5BwnKQoeJK9RPrB0FEyjD7qnFLXUsEZ8lPKnK_jzU", "timeout":120000, "extensions":{}, "allowCredentials":[], "userVerification": "required"}, "mediation": "conditional"}
mediation: "conditional"
を指定して呼び出されたcredentials.get
は、ユーザーがパスキー候補を選択して認証を完了させるまで Promiseがresolveされることはないので、ページロードの時点では上記部分までのコードが走ることになります。
これは厳密には「ページロード時処理」ではないのですが、上記のコードをページロード時に実行しただけだと、ブラウザはパスキー候補をユーザーに表示してくれません。
実際にはPasskey autofillを表示するページのinput
要素にも改修が必要です。ブラウザは、 上記の方法でcredentials.get
が呼ばれた場合、ページのinput
欄のうちautocomplete
フィールドにwebauthn
が設定されているフィールドにパスキー候補を表示します。なので、 パスキー候補を表示させたい入力欄のautocomplete
値にwebauthn
を追加してあげる必要があります。
<input required type="email" name="email" autoComplete="username webauthn"/>
これで、ユーザーがページを開いた際に、利用可能なパスキー候補が表示されるようになりました。
passkey autofillが有効なログインページでは、主に2通りのケースがあります。
パスキー候補をクリックし、ユーザーがローカル認証を完了させた場合は、ページロード時に呼んでおいたcredentials.get
がresolveされ、 authenticatorからのレスポンスが返却されます。なので、このレスポンスをbackendに送信し検証した上で、検証に成功させた場合はログインさせます。
const response=await navigator.credentials.get(options);// Promise will be resolved when local authentication succeedsreturnawait backend.postWebauthnAssertion(response.toJSON());// send the authenticator response to the backend// redirect, sign in user, etc...
この場合は通常のフォーム送信処理になるので、普通にハンドリングをすればOKです。私達のサービスの場合は、パスワード入力ページにユーザーを誘導します。
このように、パスキーでのログイン及びパスワードからの移行をシームレスにしてくれる passkey autofillですが、それだけでは解決できない課題もあります。
上でも触れたのですが、mediation: "conditional"
を指定してcredentials.get
を呼ぶと、 特定のautocomplete
valueを持つinput
フィールドにパスキー候補が表示されるようになります。これは裏を返せば、「input
要素が存在しないページではpasskey autofillでパスキー候補を表示する術がない」ということになります。
「そんなページあるの?」と思うかもしれません。マネーフォワード IDには「アカウント確認画面」というページが存在します。
これは、マネーフォワードの各サービス(家計簿・会計、etc.)にSSOする際に挟む、ログインするアカウントが正しいかを確認するためのページです。このようなページでは、そもそもinput
フィールドが存在しないため、 Passkey autofill (conditional mediation) を利用できず、結果として、前述のTraditional WebAuthnで挙げたような課題を解決できません。
理想としては、このようなinputフィールドが存在しないページでも、passkey autofillが実現しているような「パスキーを利用していることを意識させない体験」を提供したいところです。
Passkey autofillで「ログイン時のパスキー利用フロー」は改善しましたが、依然解決できていないのが「パスキー登録時のフロー」です。
この記事では主にパスキーログインについてまとめましたが、WebAuthn RPの開発側としては、そもそもユーザーにパスキーを登録してもらえないと意味がありません。
ですが、現状のWebAuthn仕様では、credentials.create
(登録API)にmediation: "conditional"
のようなオプションは存在しないので、必ず以下のようなフローを踏む必要があります。
マネーフォワード IDのようなIdPにおいて、上記のパスキー登録を行ってもらうタイミングというのは主に「アカウント登録」「ログイン」の2種類しかありません。想像していただくと分かると思うのですが、アカウント登録時やログイン時に余計なページが挟まるのは、ユーザーからすると邪魔でしかないはずです。
ユーザーからすると、IdPというのはもともと自身が利用したいサービスを利用するために通らなくてはいけない邪魔な存在、障壁であり、パスキーでログインを可能にすることでその障壁を取り除こうとしているにもかかわらず、その前段階の登録で障壁を増やす必要があるという、本末転倒な状況です。
この問題に関しては、以下のような実装を可能にするWebAuthn仕様が出てくることを期待したいです。
このような実装を可能にすることで、ログイン時だけではなく登録時も含めて、ユーザーにパスキーを意識させる必要がなくなると思っています。
今回のPasskey autofillリリースとほぼ同じタイミングで、関連する機能をリリースしています。
Passkey autofillでは、 入力欄のautocomplete
値にwebauthn
が含まれていないとパスキー候補が表示されないという話をしましたが、一部のパスワードマネージャーや拡張機能などは、DOMを強制的に書き換えてこのautocomplete
をoff
にしてしまいます。(恐らく、パスワードマネージャー自身の自動補完とブラウザの自動補完がコンフリクトしないようにするためと思われます)
このような条件ではパスキーが利用できなくなることが判明したので、「パスワードマネージャーなどを利用してEmailの自動補完を行った場合、パスワード入力画面を表示することなくそのままログインが完了する」1ステップログインという機能をリリースしました。
以下が1ステップログインの流れです。
Passkey autofillと見比べていただくと分かると思うのですが、ユーザーから見た見かけ上のUXはほぼ同じです。このような変更を加えることで、
図らずも上記2パターンのユーザー体験を揃えることができました。このような変更を通して、そもそもユーザーが「今自分がパスワードでログインしたのか、パスキーでログインしたのか」を強く意識しなくても自然とログインが完了するUXを提供していきたいと思っています。
true
であることを確認するべき(SHOULD) とWebAuthn W3C specにも記載されています。↩引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。