Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

Passkey autofillを利用したパスワードレスログイン導入で得たものと、得られなかったもの

English version of this article is available here

はじめに

こんにちは、CTO室 IDサービス開発部のyamato(@8ma10s)です。 マネーフォワード IDという、当社サービス向けのIdPを開発しています。

今回このマネーフォワード IDにおいて、パスワードを使わずに、生体認証などを利用してログインできる「パスワードレスログイン」という機能をリリースしました。

また、今回のリリースでは、既にいくつかの他社サービスで導入されているような通常のパスワードレスログインUIではなく、「Passkey autofill」という、ブラウザの自動補完を利用する新しいタイプのパスワードレスログインUI を(恐らく日本のサービスで初めて。エンドユーザーの目に触れるサービスという意味では、おそらく世界でも初めて)導入しています。

私達がどういった過程で、どのような課題を解決するためにPasskey autofillの導入を決めリリースに至ったか、また、リリース後の今後の課題についてご紹介します。

Phase 0: Before passwordless

我々がパスワードレス認証を導入したかった理由は色々ありますが、主に以下のようなものです。

  • ログインUXの向上(パスワード入れるより、顔認証や指紋認証のほうが早いケースが多い)
  • 「パスワード忘れた!」という問い合わせの削減
  • 技術的チャレンジ

将来的には「パスワードの完全無効化」なども視野に入れていますが、あくまで直近の目標としては「ユーザーが使いたいサービスにより早くアクセスできるようになればいいな」くらいの気持ちでパスワードレス認証の対応を始めました。

Phase 1: Traditional WebAuthn

後述する課題などもあり、当時は対外的にアナウンスなどはしていなかったのですが、実はパスワードレス認証の実装そのものは2022年11月の時点でユーザー向けにひっそりとリリースしていました。

当時リリースした traditional WebAuthnの流れは以下のようなものでした。

  • emailを入力する
  • 「ログインする」ボタンを押すと、認証プロンプトが表示される
  • ローカル認証1が完了するとパスキーでログインできる

「もうこの時点でパスキーでログインは完成しているのでは?」と思うかもしれません。が、この初期実装にはいくつか課題がありました。

ボタンを押すといきなり認証プロンプトが表示される

Traditional WebAuthnの認証フローは、Emailを入力してログインボタンを押すと、いきなり認証プロンプトが出てきて認証を求められます。 このように「Web UI上のボタンを押すといきなり顔認証や指紋認証を要求される」というUIは、今までのパスワードログインではあまり見なかったUIであり、パスワードからのシームレスな移行を阻害する要因になっていました。

パスキーが利用できないデバイスでも、プロンプトが表示されてしまう

WebAuthn仕様にもあるように、基本的にはWebAuthn RP2がブラウザから「サーバーで持っている情報に対応するパスキーが、そのデバイスに存在するか」を、 credentials.get を呼ぶ前に確認する手段はありません。

そのため、スマートフォンからパスキーを登録したアカウントに別のデバイスからログインしようとすると、対応するパスキーが存在しないのにもかかわらず credentials.get を呼んでしまうことになり、以下のようなプロンプトが出てきてしまいます。

ユーザーとしてはパスワード入力に進みたくて送信ボタンを押したのに、邪魔なプロンプトが出てきて、それをキャンセルすることで初めてパスワード入力画面に進めるという非常に分かりにくいUXになってしまいました。

Phase 2: Passkey Autofill

マネーフォワード IDのようないわゆる「WebAuthn RP」としては、以下の2点がパスキー導入における必須要件でした。

  1. パスキーを、ユーザーが既に慣れ親しんでるUXに近い形でシームレスに利用できるようにする
  2. パスキー導入後も、通常のパスワードログインのUXを損なわない

しかし、いわゆる "Traditional WebAuthn" に前述したような課題が存在し、移行がシームレスでなかったり、パスワードを利用しているユーザーのUXを損なってしまうことは、初期実装を進めている時点で既に分かっていたので、初期実装のリリース時に大々的に告知をして利用ユーザー数を増やすことはしませんでした。

我々がこれらを解決する次のステップとして置いたのが、Passkey autofillの実装でした。

Passkey Autofillとは

WebAuthn界隈では "conditional mediation"や "conditional UI" と呼ばれている技術です。

2022年5月にApple, Google, Microsoftの3社が「パスキーのサポートを拡大する」という共同声明を出しました。 その後、その取り組みの一環として、パスキーをよりパスワードと似たUXの中で共存させる仕組みとしてWWDC22Googleのパスキーサポート発表の中でも触れられていたのが、このPasskey autofillです。

機能としては、「利用しているデバイスでログインに利用できるパスキーの候補を、ブラウザの自動補完機能を使って表示する」というものです。 文字だと若干分かりづらいので、以下の動画をご覧ください。

Passkey Autofillが解決する課題

Traditional WebAuthnで解決できていなかった課題を、Passkey autofillはいくつか解決できています。

パスキーを、ユーザーが既に慣れ親しんでるUXに近い形でシームレスに利用できるようにする

前述の通り、Traditional WebAuthnにおいては、パスワードでログインする際と劇的に違うUIになってしまい、ユーザーの混乱を招く懸念がありました。

Passkey autofillを使うと、ログインの流れは以下のようになります。

  • Email入力欄の下に「利用できるパスキー候補」が自動的に表示される
  • ユーザーが候補をクリックすると、認証プロンプトが表示される
  • 認証を通ると、ログインが完了する

この流れは、ブラウザなどに標準で搭載されているパスワードマネージャーでパスワードを補完する流れと全く同じです。

このように、パスワードログインとパスキーログインのUIをほぼ同一のものにすることで、 ユーザーにそもそもパスキーとパスワードの違いを意識させないということが達成できます。

パスキー導入後も、通常のパスワードログインのUXを損なわない

Traditional WebAuthnでは、「パスキーを登録したデバイスと異なるデバイスからログインを行おうとすると、パスキーではログイン不可能なのにもかかわらず認証プロンプトが表示されてしまう」という問題がありました。

Passkey autofillは、「デバイスで利用可能なパスキーしか表示されない」という特徴があります。 なので、パスキーを利用できるデバイスではパスキーでログインしてもらい、利用できないデバイスでは自然にパスワード入力画面に誘導できるため、より理想に近いUXのパスキーログインを提供できます。

実装例

パスキーが解決する課題について一通りご紹介したところで、実際のコードの解説をしていきたいと思います。

Traditional WebAuthn

Passkey autofillを利用していないtraditional WebAuthnについては、解説記事も既に沢山あるのでここでは触れません。

Passkey autofillのコード例を理解するにあたっては、

  • バックエンドからoptions オブジェクト(認証時の設定を定義したオブジェクト)を取得する
  • 取得した optionsオブジェクトを引数にnavigator.credentials.get を呼ぶ
  • get メソッドの返り値をバックエンドで検証する

という一連の流れを理解していれば大丈夫かと思います。

Traditional WebAuthnについて更に詳しく知りたい方は、以下のcodelabsなどが参考になるかと思います。 https://developers.google.com/codelabs/webauthn-reauth

Passkey autofill全体の流れ

まず、全体の流れをシーケンス図で。

そしてこちらが、ページロード時に走るフロントエンドのコードサンプルです。 ※ ブラウザが特定のAPIを提供しているか、などのチェック部分は省略しています

const sendAuthenticatorResponseIfWebauthnAvailable = async () => {
  try {
    // if browser is webauthn-compatible, fetch options from server
    if (!(navigator.credentials &&
      navigator.credentials.create &&
      navigator.credentials.get &&
      window.PublicKeyCredential &&
      await PublicKeyCredential.isConditionalMediationAvailable())) {
      return false;
    }

    const optionsJSON = await backend.fetchWebauthnAssertionOptions();
    if (optionsJSON != null) {
      options = webauthn.parseRequestOptionsFromJSON(optionsJSON);
    } else {
      return null;
    }

    options['mediation'] = 'conditional';

    const response = await navigator.credentials.get(options);
    return await backend.postWebauthnAssertion(response.toJSON()); // send the authenticator response to the backend
  } catch (e) {
    console.log(e);
    return null;
  }
};

このシーケンス図及びコードをベースに、順を追って解説していきます。

ページロード時

traditional WebAuthn では、ほとんどの処理が「Submitボタン押下時」に行われていましたが、 passkey autofillでは多くの処理が「ページのロード時」に行われます。

  • WebAuthn及び conditionalMediation が利用できるかのチェック
  • backendから options オブジェクトを取得
  • credentials.get の呼び出し
  • (autocomplete 値の設定)

ですので、上記のコードは(Reactで言えば useEffectなどを利用して)passkey autofillを利用したいページのロード時に走らせることになります。

WebAuthn及び conditional mediation が利用できるかのチェック (シーケンス図 1, 2)

まずは、フロントエンド側でWebAuthnが利用できるか、及び conditional mediation (= passkey autofill) が利用できるかのチェックを行います。

conditional mediationに関しては、 PublicKey.isConditionalMediationAvailable() という非同期メソッドがあるので、こちらの結果を確認すればOKです3

// if browser is webauthn-compatible, fetch options from server
if (!(navigator.credentials &&
  navigator.credentials.create &&
  navigator.credentials.get &&
  window.PublicKeyCredential &&
  await PublicKeyCredential.isConditionalMediationAvailable())) {
  return false;
}

backendから options オブジェクトを取得 (シーケンス図3,4)

次に credentials.get にわたす optionsオブジェクトが必要です。 options オブジェクトに含まれる challenge はbackend側で生成されるべきなので、 backendにリクエストをします。

const optionsJSON = await backend.fetchWebauthnAssertionOptions();
if (optionsJSON != null) {
  options = webauthn.parseRequestOptionsFromJSON(optionsJSON);
} else {
  return null;
}
const fetchWebauthnAssertionOptions = async () => {
  const response = await fetch(options_webauthn_assertion_path(), {
    method: 'POST',
    body: {},
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
  });

  switch (response.status) {
    case 200:
      return response.json();
    default:
      return null;
  }
};

結果、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);

最終的にはこのような optionscredentials.get に渡すことになります。

{
  "publicKey": {
    "challenge": "DZ5BwnKQoeJK9RPrB0FEyjD7qnFLXUsEZ8lPKnK_jzU",
    "timeout": 120000,
    "extensions": {},
    "allowCredentials": [],
    "userVerification": "required"
  },
  "mediation": "conditional"
}

mediation: "conditional" を指定して呼び出された credentials.get は、ユーザーがパスキー候補を選択して認証を完了させるまで Promiseがresolveされることはないので、ページロードの時点では上記部分までのコードが走ることになります。

autocomplete 値の設定

これは厳密には「ページロード時処理」ではないのですが、上記のコードをページロード時に実行しただけだと、ブラウザはパスキー候補をユーザーに表示してくれません。

実際にはPasskey autofillを表示するページの input要素にも改修が必要です。 ブラウザは、 上記の方法で credentials.get が呼ばれた場合、ページの input欄のうち autocomplete フィールドに webauthn が設定されているフィールドにパスキー候補を表示します。 なので、 パスキー候補を表示させたい入力欄の autocomplete 値に webauthn を追加してあげる必要があります。

<input
    required
    type="email"
    name="email"
    autoComplete="username webauthn"
/>

これで、ユーザーがページを開いた際に、利用可能なパスキー候補が表示されるようになりました。

ユーザー操作時

passkey autofillが有効なログインページでは、主に2通りのケースがあります。

  • ユーザーがpasskey autofillで表示されたパスキー候補をクリックする
  • 利用できるパスキー候補がない or 表示されたパスキー候補を無視し、普通にemailを入力する

ユーザーがpasskey autofillで表示されたパスキー候補をクリックした場合 (シーケンス図 7-11)

パスキー候補をクリックし、ユーザーがローカル認証を完了させた場合は、ページロード時に呼んでおいた credentials.get がresolveされ、 authenticatorからのレスポンスが返却されます。 なので、このレスポンスをbackendに送信し検証した上で、検証に成功させた場合はログインさせます。

const response = await navigator.credentials.get(options); // Promise will be resolved when local authentication succeeds
return await backend.postWebauthnAssertion(response.toJSON()); // send the authenticator response to the backend

// redirect, sign in user, etc...

利用できるパスキー候補がない or 表示されたパスキー候補を無視し、普通にemailを入力する

この場合は通常のフォーム送信処理になるので、普通にハンドリングをすれば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仕様が出てくることを期待したいです。

  • アカウント登録のパスワード設定時に、「ついでに」パスキーも登録できる
  • (OSパスワードマネージャーなどを利用している場合)パスワードでのログイン時に、「ついでに」パスキーも登録できる

このような実装を可能にすることで、ログイン時だけではなく登録時も含めて、ユーザーにパスキーを意識させる必要がなくなると思っています。

余談: パスワードマネージャーと、1ステップログインの話

今回のPasskey autofillリリースとほぼ同じタイミングで、関連する機能をリリースしています。

Passkey autofillでは、 入力欄の autocomplete値に webauthnが含まれていないとパスキー候補が表示されないという話をしましたが、一部のパスワードマネージャーや拡張機能などは、DOMを強制的に書き換えてこの autocompleteoffにしてしまいます。 (恐らく、パスワードマネージャー自身の自動補完とブラウザの自動補完がコンフリクトしないようにするためと思われます)

このような条件ではパスキーが利用できなくなることが判明したので、「パスワードマネージャーなどを利用してEmailの自動補完を行った場合、パスワード入力画面を表示することなくそのままログインが完了する」1ステップログインという機能をリリースしました。

以下が1ステップログインの流れです。

  • Email入力欄の下に、パスワードマネージャーが持っているパスワード候補が表示される
  • ユーザーが候補をクリックする。(パスワードマネージャーの設定によっては認証プロンプトが表示される)
  • ログインボタンを押すと、ログインが完了する

Passkey autofillと見比べていただくと分かると思うのですが、ユーザーから見た見かけ上のUXはほぼ同じです。 このような変更を加えることで、

  • ブラウザの自動補完や、パスワードマネージャーなどを利用した際のパスワードログイン
  • Passkey autofillを利用したパスキーログイン

図らずも上記2パターンのユーザー体験を揃えることができました。 このような変更を通して、そもそもユーザーが「今自分がパスワードでログインしたのか、パスキーでログインしたのか」を強く意識しなくても自然とログインが完了するUXを提供していきたいと思っています。

注釈


  1. デバイスでパスキーを利用するために使える認証手段。デバイスの種類にもよるが、PIN、顔認証、指紋認証など。
  2. WebAuthnにおけるRPとは、Webauthn APIを利用するサービスのこと。この場合はマネーフォワード IDがそれに相当する。
  3. Passkey autofill (conditional mediation) を利用するためには、このメソッドの返り値が true であることを確認するべき(SHOULD) と WebAuthn W3C specにも記載されています。