🔑

Rails(Devise)でパスワード認証に加えてパスキー(WebAuthn)でもログインできるようにするステップバイステップ

2023/11/13に公開

パスキーの普及も進んできましたね。

私のWindows11でもWindows Helloを使ったりAndroid/iOS端末を使ったローミングができたりととても便利で簡単です。
ニンテンドーアカウントでもパスキーによるログインが可能になりました。もちろん設定しましたよね。
https://www.nintendo.co.jp/support/nintendo_account/passkey/index.html

Railsでもパスキーを組み込む方法がいろいろと説明されています。
https://techracho.bpsinc.jp/hachi8833/2023_10_19/134237
https://techracho.bpsinc.jp/hachi8833/2023_09_14/134754#2-2

私はdeviseを使うことが多いので、devise-passkeysを触ってみたのですが、use passkeys instead of passwordsとある通りパスワードに替えてパスキーを使うためのもので、私のやりたい、これまでのパスワード(や各種ソーシャルログイン)はそのままに、パスキー『でも』ログインできるようにするにはマッチしないように感じたのと、そもそもの仕組みを理解するためにまずは自分で実装してみることにしました。


成果物

https://github.com/takeyuweb/rails-devise-webauthn-demo

Render.comの無料プランに置いています。
https://devise-webauthn-ruby-demo.onrender.com

パスワードログイン

従来通りのログイン方法も使用できます。

パスキーの登録

1. パスキーの作成 2. 登録完了
登録完了

パスキーでログイン

対応デバイスでならWebAuthn Autofillでこのサイトで作成したパスキーを選択するUIを表示し、選択するだけでログインできます。

Windows 11からAndroidで作成したパスキーを使ってログイン(ローミング)

Windows 11ではWindows Helloも使えますが、Androidですでに登録していればAndroidを使ってログインできます。

Windows側 Android側

複数のキーを登録できる

たとえばWindows/Android/iOSそれぞれの端末にパスキーを作成することで、どの端末だけでもログインできるし、それぞれの端末からほかの端末を使ってログインすることもできます。

パスキー関係の用語整理

まず理解のため用語を整理しておきます。
ざっくりとした私の理解は以下の通りです。

パスキー

FIDO2を使って認証し、認証に必要な鍵を複数端末間で同期する仕組み。
たとえばAppleの場合、iCloudキーチェインでパスワードと同じように端末間で共有される。
https://ritou.hatenablog.com/entry/2021/06/15/060000

FIDO2

WebAuthn + CTAP で構成された、プラットホーム認証器または外部(またはローミング)認証器を使って、パスワードレス、2要素および多要素の認証を実現する認証技術。
https://fidoalliance.org/specifications/

WebAuthn

WebAuthnはブラウザから認証器を使用するためのブラウザAPI。
https://www.w3.org/TR/webauthn-2/
https://developer.mozilla.org/ja/docs/Web/API/Web_Authentication_API

認証器

鍵ペアを使ってユーザー認証をするための機器。
Windows Hello、Apple FaceID、Androidの指紋認証などの組み込みのものをプラットホーム認証器、YubiKeyなどの取り外し可能なものを外部認証器と呼ぶ。WindowsからCTAP2でAndroid指紋認証を使う場合は、WindowsからみてAndroid指紋認証器はローミング認証器。

CTAP

認証器を持たないデバイスでもWebAuthnを使えるようにするための外部認証器との通信プロトコル。

CTAP1 (FIDO U2F)

FIDO U2Fデバイスを二要素認証器として使うための仕様。

CTAP2

外部認証器をパスワードレス、多要素・二要素認証に使うための仕様。

パスキー(WebAuthn)ログインのためにWebアプリに実装するもの

実装に取りかかる前に、何を実装する必要があるのか整理しておきます。

パスキーの登録

クライアントサイド実装

  1. JavaScriptで、サーバー(Relying Party(RP):証明書利用者)に資格情報作成オプションの生成を依頼
  2. 受け取った作成オプションをWebAuthnの navigator.credentials.create() に渡し、認証器から公開鍵などの応答を受け取る
  3. 応答をサーバーに送る

サーバサイド実装

  1. クライアントからの求めに応じて、ログイン中ユーザーを識別するための情報を含む資格情報作成オプションを生成する
  2. クライアントから受け取った認証器からの応答を検証して、正当なものなら応答に含まれる公開鍵をログイン中のユーザーと紐付けて保存する

パスキーによるログイン

クライアントサイド実装

  1. JavaScriptで、サーバーに資格情報取得オプションの生成を依頼
  2. 受け取った取得オプションをWebAuthnの navigator.credentials.get() に渡し、認証器の秘密鍵を使って電子署名をしてもらい、応答を受け取る
  3. 応答をサーバーに送る

サーバサイド実装

  1. クライアントからの求めに応じて、資格情報取得オプションを生成する
  2. クライアントから受け取った認証器からの応答を検証して、正当なものなら使用した鍵に対応するユーザーを識別し、ログイン処理を継続する

オートフィルの対応

クライアントサイド実装

  1. ログインフォームの入力欄に autocomplete="webauthn" を追加
  2. ページロード時に WebAuthn と Conditional Mediation(条件付き仲介?)が利用可能か調べ、利用可能ならサーバーに資格情報取得オプションの生成を依頼
  3. 受け取った取得オプションをWebAuthnの navigator.credentials.get() に渡し待機する(ユーザーがパスキーを選択すると、応答が返ってくる)
  4. 応答をサーバーに送る

https://w3c.github.io/webauthn/#dom-publickeycredential-isconditionalmediationavailable
https://w3c.github.io/webappsec-credential-management/#user-mediation
https://zenn.dev/gebo/articles/spring-security-fido2-login-5

ライブラリ

@github/webauthn-json

WebAuthn APIそのままではバイナリデータを扱う必要があるなど使いづらいので、扱いやすくするためのJavaScriptライブラリを使用します。
https://github.com/github/webauthn-json

webauthn-ruby

資格情報作成・取得オプションの生成、応答の検証などRubyでRPを実装するために必要な機能が揃っている素晴らしいgemです。
https://github.com/cedarcode/webauthn-ruby

実装手順

0. Deviseのセットアップ

$ rails new rails-devise-webauthn-demo -d postgresql -a propshaft
$ bundle add devise
$ rails g devise:install
$ rails g devise User
$ rails db:migrate
$ rails g controller MyPages show
config/environments/development.rb
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
config/routes.rb
authenticate :user do
  root "my_pages#show"
end
app/views/my_pages/show.html.erb
<p>Hello, <%= current_user.email %></p>

1. パスキーを登録できるようにする

パスキー保存先のモデルを作成

WebAuthnの公開鍵を保存する Passkey モデルと、公開鍵とDeviseの Userを紐付けるユーザーハンドルとして使う webauthn_id を追加します。
webauthn_idusers テーブルに追加する方が一般的だと思いますが、私は nullable なカラムを増やしたくなかったので、テーブルを分けました。

bundle exec rails g model WebauthnUser user:references webauthn_id:string
bundle exec rails g model Passkey webauthn_user:references label:string external_id:string public_key:string sign_count:integer last_used_at:datetime
bundle exec rails db:migrate
20231110044420_create_webauthn_users.rb
class CreateWebauthnUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :webauthn_users do |t|
      t.references :user, null: false, foreign_key: true, index: { unique: true }
      t.string :webauthn_id, null: false, index: { unique: true }

      t.timestamps
    end
  end
end
20231110044426_create_passkeys.rb
class CreatePasskeys < ActiveRecord::Migration[7.1]
  def change
    create_table :passkeys do |t|
      t.references :webauthn_user, null: false, foreign_key: true
      t.string :label, null: false
      t.string :external_id, null: false
      t.string :public_key, null: false
      t.integer :sign_count, default: 0, null: false
      t.datetime :last_used_at, null: false

      t.timestamps

      t.index [:webauthn_user_id, :external_id], unique: true
    end
  end
end
app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  # 以下を追加
  has_one :webauthn_user, dependent: :destroy
  delegate :passkeys, to: :webauthn_user, allow_nil: true
end
app/models/webauthn_user.rb
class WebauthnUser < ApplicationRecord
  belongs_to :user
  has_many :passkeys, dependent: :destroy
  validates :webauthn_id, presence: true, uniqueness: true
end
app/models/passkey.rb
class Passkey < ApplicationRecord
  belongs_to :webauthn_user

  validates :label, presence: true
  validates :external_id, presence: true, uniqueness: { scope: :webauthn_user_id }
  validates :public_key, presence: true
  validates :sign_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
  validates :last_used_at, presence: true
end

パスキー一覧画面を作成

$ rails g controller Passkeys index
config/routes.rb
  authenticate :user do
    root "my_pages#show"
    resources :passkeys, only: [:index, :create, :destroy]  # 追加
  end
app/controllers/passkeys_controller.rb
class PasskeysController < ApplicationController
  def index
    @passkeys = current_user.passkeys
  end

  def create
    raise NotImplementedError.new("TODO")
  end

  def destroy
    @passkey = current_user.passkeys.find(params[:id])
    @passkey.destroy
    redirect_to passkeys_path
  end
end
app/views/my_pages/show.html.erb
<h1>MyPages#show</h1>
<p>Find me in app/views/my_pages/show.html.erb</p>
<p>Hello, <%= current_user.email %></p>

<!-- リンク追加 -->
<p><%= link_to 'Passkeys', passkeys_path %></p>
app/views/passkeys/index.html.erb
<h1>Passkeys#index</h1>
<p>Find me in app/views/passkeys/index.html.erb</p>

<% if @passkeys.present? %>
  <table>
    <thead>
      <tr>
        <th>Label</th>
        <th>Last Userd</th>
        <th>Sign Count</th>
        <th>Action</th>
      </tr>
    </thead>
    <tbody>
    <% @passkeys.each do |passkey| %>
      <tr>
        <td><%= passkey.label || '(no name)' %></td>
        <td>Last Used: <%= passkey.last_used_at ? l(passkey.last_used_at) : 'Never' %></td>
        <td>Sign Count: <%= passkey.sign_count %></td>
        <td><%= button_to 'Delete', passkey, method: :delete %></td>
      </tr>
    <% end %>
    </tbody>
  </table>
<% else %>
  <p>No passkeys found.</p>
<% end %>

パスキー登録

サーバーサイド

WebAuthnのサーバーサイド(RP)実装には webauthn-ruby gem を使います。

$ bundle add webauthn

RPの設定を作成します。
これにより webauthn-ruby 規定の WebAuthn::RelyingParty ( WebAuthn.configuration.relying_party ) のオプションを指定できるので、都度 WebAuthn::RelyingParty.new して設定する必要がなくなります。

https://github.com/cedarcode/webauthn-ruby/blob/43a491b92c118bb6acdfb179b30349ba3a2046ae/lib/webauthn/configuration.rb#L18-L39

config/initializers/webauthn.rb
WebAuthn.configure do |config|
  # This value needs to match `window.location.origin` evaluated by
  # the User Agent during registration and authentication ceremonies.
  config.origin = "http://localhost:3000"

  # Relying Party name for display purposes
  config.rp_name = "Rails Devise Webauthn Demo"

  # Optionally configure a client timeout hint, in milliseconds.
  # This hint specifies how long the browser should wait for any
  # interaction with the user.
  # This hint may be overridden by the browser.
  # https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout
  # config.credential_options_timeout = 120_000

  # You can optionally specify a different Relying Party ID
  # (https://www.w3.org/TR/webauthn/#relying-party-identifier)
  # if it differs from the default one.
  #
  # In this case the default would be "auth.example.com", but you can set it to
  # the suffix "example.com"
  #
  # config.rp_id = "example.com"

  # Configure preferred binary-to-text encoding scheme. This should match the encoding scheme
  # used in your client-side (user agent) code before sending the credential to the server.
  # Supported values: `:base64url` (default), `:base64` or `false` to disable all encoding.
  #
  # config.encoding = :base64url

  # Possible values: "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "RS256", "RS384", "RS512", "RS1"
  # Default: ["ES256", "PS256", "RS256"]
  #
  # config.algorithms << "ES384"
end

クライアントからの要求に対して資格情報作成オプションを作成するcontrollerを作成します。

$ rails g controller Passkeys::CreationOptions
config/routes.rb
  authenticate :user do
    root "my_pages#show"
    resources :passkeys, only: [:index, :create, :destroy] do
      collection do
        resources :creation_options, only: [:create], module: :passkeys  # 追加
      end
    end
  end
app/controllers/passkeys/creation_options_controller.rb
class Passkeys::CreationOptionsController < ApplicationController
  # See https://github.com/cedarcode/webauthn-ruby#options-for-create
  # https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialcreationoptions
  def create
    passkey_params = params.require(:passkey).permit(:label)

    creation_options = WebAuthn::Credential.options_for_create(
      user: { id: find_or_create_webauthn_id, name: current_user.email, display_name: passkey_params[:label] },
      exclude: current_user.passkeys.pluck(:external_id), # 登録済みの鍵は除外する。
      authenticator_selection: {
        resident_key: "required", # 2023年11月現在、Android Chrome でパスキーを使うには residentKey が必須
        require_resident_key: true,
        user_verification: "required" # 認証器でのユーザー認証を要求する
      }
    )

    session[:current_webauthn_registration_challenge] = creation_options.challenge

    render json: creation_options
  end

  private

  def find_or_create_webauthn_id
    return current_user.webauthn_user.webauthn_id if current_user.webauthn_user

    current_user.create_webauthn_user(webauthn_id: WebAuthn.generate_user_id).webauthn_id
  end
end

クライアントから認証器の応答を受け取り、公開鍵を登録するアクションを書きます。
これは POST /passkeys とします。

app/controllers/passkeys_controller.rb
  def create
    passkey_params = params.require(:passkey).permit(:label, :credential)
    stored_registration_challenge = session[:current_webauthn_registration_challenge]
    parsed_credential = JSON.parse(passkey_params[:credential]) rescue nil
    webauthn_credential = WebAuthn::Credential.from_create(parsed_credential)
    webauthn_credential.verify(stored_registration_challenge, user_verification: true) # 検証失敗したら `WebAuthn::VerificationError` が発生するので、実際に使うときはエラーハンドリングを考える

    current_user.passkeys.create!(
      label: passkey_params[:label],
      external_id: webauthn_credential.id,
      public_key: webauthn_credential.public_key,
      sign_count: webauthn_credential.sign_count,
      last_used_at: Time.current
    )

    redirect_to passkeys_path
  end
クライアントサイド

今回JavaScriptは importmap-railsstimulus で実装することにしたのでその初期設定

$ rails importmap:install
$ rails stimulus:install

WebAuthn APIをそのままは使いづらいということで、 @github/webauthn-json を使います。

$ ./bin/importmap pin @github/webauthn-json/browser-ponyfill
config/importmap.rb
# Pin npm packages by running ./bin/importmap

pin "application", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@github/webauthn-json/browser-ponyfill", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.browser-ponyfill.js"
app/views/passkeys/index.html.erb
<h1>Passkeys#index</h1>
<p>Find me in app/views/passkeys/index.html.erb</p>

(中略)

<!-- 追記 -->
<%= render 'new_form' %>
app/views/passkeys/_new_form.html.erb
<%= tag.div data: {
   controller: "new-passkey",
   "new-passkey-creation-options-url-value": creation_options_path
 } do %>
  <%= form_with url: passkeys_path, model: Passkey.new, method: :post, data: {
    'new-passkey-target': 'form',
    action: 'new-passkey#submit',
  } do |f| %>
    <%= f.hidden_field :credential, data: { 'new-passkey-target': 'credentialField' } %>
    <%= f.text_field :label, placeholder: 'Device Name', required: true %>
    <%= f.submit 'Set up a passkey' %>
  <% end %>
<% end %>

フォームのサブミットボタンを押すと、

  1. フォームの内容(鍵の名前)をサーバーに送信し、資格情報作成オプションJSONを取得する。資格情報作成オプションの displayName に鍵の名前を使いたかったのでフォームの内容を送信したが、これはなくてもよい。
  2. 資格情報作成オプションJSONが得られたら、 webauthn-json を使って WebAuthn API 経由で認証器に渡す。
  3. (ここでブラウザ上のポップアップが表示され、パスキーの作成が案内される)
  4. 認証器から応答が返され(Promiseが解決され)たら、応答をサーバーに送り、公開鍵を登録する。
app/javascript/controllers/new_passkey_controller.js
import { Controller } from "@hotwired/stimulus";
import {
  create,
  parseCreationOptionsFromJSON
} from "@github/webauthn-json/browser-ponyfill";

export default class extends Controller {
  static targets = ["form", "credentialField"];
  static values = {
    creationOptionsUrl: String
  };

  submit(event) {
    event.preventDefault();

    const data = new FormData(this.formTarget);
    data.delete('_method');

    fetch(this.creationOptionsUrlValue, {
      method: "POST",
      headers: {
        "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
        "Accept": "application/json",
      },
      body: data
    })
      .then(response => response.json())
      .then(async creationOption => {
        console.log(creationOption);
        // ここに成功時の処理を書く
        const credentialCreationOptions = parseCreationOptionsFromJSON({ publicKey: creationOption });
        const credentialCreationResponse = await create(credentialCreationOptions);
        this.credentialFieldTarget.value = JSON.stringify(credentialCreationResponse);
        this.formTarget.submit();
      })
      .catch(error => {
        console.error(error);
        // ここにエラー時の処理を書く
      });
  }
}

2. パスキーでログインできるようにする

認証の方法はいくつか考えられますが、今回はDeviseのログイン機能をカスタマイズして次のようにします。

  1. メールアドレスを入力して『パスキーでログイン』をクリック
  2. サーバーにメールアドレスから候補となる公開鍵IDの一覧を含む資格情報取得オプションを作成依頼
  3. (ユーザーがパスキーを選択)
  4. 認証器からの応答をログインフォームに含めてPOST
  5. 応答を検証して問題なければログイン継続

Deviseのカスタマイズ準備

カスタマイズのため、appディレクトリ内にcontrollerとviewを生成します。

$ rails g devise:views users -v sessions
$ rails g devise:controllers users

サーバーサイド

資格情報取得オプションを作成するためのcontrollerを作成します。

$ rails g controller Passkeys::RequestOptions
config/routes.rb
Rails.application.routes.draw do
  # cotrollers: を追加
  devise_for :users, controllers: { sessions: 'users/sessions' }

  # 省略

  # 追加
  # 資格情報取得オプションはログインなしでアクセスできる必要があるので、authorizeの外に書きます
  scope :passkeys do
    resources :request_options, only: [:create], module: :passkeys
  end
app/controllers/passkeys/request_options_controller.rb
class Passkeys::RequestOptionsController < ApplicationController
  def create
    user_params = params.require(:user).permit(:email)
    user = User.find_by!(email: user_params[:email])

    # https://www.w3.org/TR/webauthn/#dictionary-assertion-options
    request_options = WebAuthn::Credential.options_for_get(
      user_verification: "required",  # ユーザー認証を要求する
      allow: user.passkeys&.pluck(:external_id),  # メールアドレスからユーザーが特定し、登録済みのパスキーで絞り込む
    )

    session[:current_webauthn_authentication_challenge] = request_options.challenge

    render json: request_options
  end
end

Deviseのログイン(セッション作成)をオーバーライドして、パスキーの場合はパスワードログインを迂回して進めるようにします。

Deviseとしてはストラテジーを作成するのが正しいと思いますが、今回は簡単にWebAuthnを理解したいので、素朴な方法で実装します。

app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
  # POST /resource/sign_in
  def create
    if authrized_passkey.present?
      self.resource = authrized_passkey.webauthn_user.user
      set_flash_message!(:notice, :signed_in)
      sign_in(resource_name, resource)
      authrized_passkey.update!(last_used_at: Time.current)
      yield resource if block_given?
      respond_with resource, location: after_sign_in_path_for(resource)
    else
      super
    end
  end

  private

  def authrized_passkey
    return @authrized_passkey if defined?(@authrized_passkey)

    passkey_params = params.require(:passkey).permit(:credential)
    parsed_credential = JSON.parse(passkey_params[:credential]) rescue nil
    return @authrized_passkey = nil unless parsed_credential

    webauthn_credential = WebAuthn::Credential.from_get(parsed_credential)
    passkey = Passkey.find_by(external_id: webauthn_credential.id)
    return @authrized_passkey = nil unless passkey

    stored_authentication_challenge = session[:current_webauthn_authentication_challenge]

    verified = webauthn_credential.verify(
      stored_authentication_challenge,
      public_key: passkey.public_key,
      sign_count: passkey.sign_count,
      user_verification: "required"
    )

    @authrized_passkey = verified ? passkey : nil
  end
end

クライアントサイド

ログイン画面を変更して、『Log in with a passkey』ボタンを押すとパスキーを使うようにします。

app/views/users/sessions/new.html.erb
<h2>Log in</h2>

<%= tag.div data: { controller: 'authorize-passkey', 'authorize-passkey-request-options-url-value': request_options_path } do %>

  <%= form_for(resource, as: resource_name, url: session_path(resource_name), data: { 'authorize-passkey-target': 'form' }) do |f| %>
    <div class="field">
      <%= f.label :email %><br />
      <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
    </div>

    <div class="field">
      <%= f.label :password %><br />
      <%= f.password_field :password, autocomplete: "current-password" %>
    </div>

    <% if devise_mapping.rememberable? %>
      <div class="field">
        <%= f.check_box :remember_me %>
        <%= f.label :remember_me %>
      </div>
    <% end %>

    <%= hidden_field_tag "passkey[credential]", nil, data: { 'authorize-passkey-target': 'credentialField' } %>

    <div class="actions">
      <%= f.submit "Log in" %>
      <%= f.button "Log in with a passkey", data: { action: "authorize-passkey#signInWithPasskey" } %>
    </div>
  <% end %>

<% end %>

<%= render "users/shared/links" %>
app/javascript/controllers/authorize_passkey_controller.js
import { Controller } from "@hotwired/stimulus";
import {
  get,
  parseRequestOptionsFromJSON
} from "@github/webauthn-json/browser-ponyfill";

export default class extends Controller {
  static targets = ["form", "credentialField"];
  static values = {
    requestOptionsUrl: String
  };

  signInWithPasskey(event) {
    event.preventDefault();
    this.getChallengeAndSubmitCredential();
  }

  getChallengeAndSubmitCredential() {
    const data = new FormData(this.formTarget);
    data.delete('_method');

    fetch(this.requestOptionsUrlValue, {
      method: "POST",
      headers: {
        "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
        "Accept": "application/json",
      },
      body: data
    })
      .then(response => response.json())
      .then(async requestOption => {
        console.log(requestOption);
        // ここに成功時の処理を書く
        const credentialRequestOptions = parseRequestOptionsFromJSON({ publicKey: requestOption });
        const credentialRequestResponse = await get(credentialRequestOptions);
        this.credentialFieldTarget.value = JSON.stringify(credentialRequestResponse);
        this.formTarget.submit();
      })
      .catch(error => {
        console.error(error);
        // ここにエラー時の処理を書く
      });
  }
}

3. オートフィルに対応させる

ログインフォームにfocusがあたったとき、パスキーがあれば選択してそのままログイン、なければ通常のログイン、のように既存の動作を変えず、パスキーの使い勝手を向上させることができます。

オートフィルの場合、ページロード時にフォームへの入力はなしに資格情報取得オプションを作成する必要があるので、controllerを修正します。

app/controllers/passkeys/request_options_controller.rb
class Passkeys::RequestOptionsController < ApplicationController
  def create
    user_params = params.require(:user).permit(:email)
    user = User.find_by(email: user_params[:email])

    request_options = WebAuthn::Credential.options_for_get(
      user_verification: "required",  # ユーザー認証を要求する
      allow: user ? user.passkeys.pluck(:external_id) : nil,  # メールアドレスからユーザーが特定できれば、登録済みのパスキーで絞り込む
    )

ログインフォームのログイン名入力欄に autocomplete="webauthn" を追加します。

app/views/users/sessions/new.html.erb
    <!-- 省略 -->
    <div class="field">
      <%= f.label :email %><br />
      <%= f.email_field :email, autofocus: true, autocomplete: "email webauthn" %>
    </div>
    <!-- 省略 -->

ページロード時に WebAuthn と Conditional Mediation(条件付き仲介?)が利用可能か調べ、利用可能ならサーバーに資格情報取得オプションの生成を依頼するようにします。
受け取った取得オプションをWebAuthnの get メソッドに渡すと Promise が得られ、ユーザーがパスキーを選択すると認証器からの応答が得られます。

app/javascript/controllers/authorize_passkey_controller.js
import { Controller } from "@hotwired/stimulus";
import {
  supported,
  get,
  parseRequestOptionsFromJSON
} from "@github/webauthn-json/browser-ponyfill";

export default class extends Controller {
  // 省略

  async formTargetConnected(element) {
    // Check if the browser supports WebAuthn.
    if (supported()) {
      if (PublicKeyCredential.isConditionalMediationAvailable) {
        // Check if the browser supports conditional mediation.
        // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/isConditionalMediationAvailable
        const isCMA = await PublicKeyCredential.isConditionalMediationAvailable();
        if (isCMA) {
          console.log("CMA is available");
          this.getChallengeAndSubmitCredential(isCMA);
        } else {
          console.log("CMA is not available");
        }
      } else {
        console.log("CMA is not supported");
      }
    } else {
      console.log("WebAuthn is not supported");
    }
  }

  // 省略

  getChallengeAndSubmitCredential(isCMA) { // 引数を追加
    // 省略
        const credentialRequestOptions = parseRequestOptionsFromJSON({ publicKey: requestOption });
        if (isCMA) {
	  // Conditional Mediation の場合は "conditional" を指定する
	  // https://w3c.github.io/webappsec-credential-management/#mediation-requirements
          credentialRequestOptions['mediation'] = 'conditional';
        }
        const credentialRequestResponse = await get(credentialRequestOptions);
    // 省略
  }

まとめ

順序立てて実装してみることで、WebAuthnの使い方を理解でき、パスキーログインを組み込むことができました。特に Conditional Mediationを使ったオートフィルは、パスワードマネージャーと同様の使い勝手で体験が良いです。
今後普及が進むと思いますので、今のうちに触って感覚をつかんでおきたいところです。

タケユー・ウェブ株式会社

Discussion