Cloudflare Workers で Passkey autofill できる WebAuthn RP を自作してみる
はじめに
この記事は、Cloudflare Workers を使って CDN エッジ上で動作し、WebAuthn のみをサポートする ミニ IdP を作成してみたという話です。ソースコードは https://github.com/atpons/idp にあります。
実装する
フロントエンド / バックエンド
honojs/hono などの Cloudflare Workers に特化したフレームワークなどもありますが、今回は Hono ではなく、よりフルスタックなフレームワークとして Remix を採用しました。
フロントエンドとバックエンドについては Remix で実装することにします。なんとコンポーネントを書いたのはログイン画面だけです。
WebAuthn
WebAuthn の RP を実装する際はまず https://github.com/herrjemand/awesome-webauthn を見ることをおすすめします。
ブラウザ側の navigator.credentials
(MDN) 呼んで良い感じにサーバーで処理するだけだから簡単・・・ではありません。
理由は WebAuthn の認証器から送られるデータは CBOR という形式でエンコードされたものを JS 上で ArrayBuffer を扱う必要があるからです。JSON ではないのです。(Buffer 周りのサポートが workerd によって強化されたので、これも Workers 利用のきっかけでした)
WebAuthn に話を戻しますと、GW の始めに他の言語 (Go)で WebAuthn をライブラリを使って実装していたので、JS 同士ならなんでもあるでしょwと意気込んでみたものの、思いのほか選択肢がなく、SimpleWebAuthn というものを使いました。
また、サーバー、クライアントともに Base64url 形式の Base64 を扱う必要があります。これも侮っていると JS ではなかなか扱うのが難しいです。Node では最近、Workers では現状使えなさそうだったので @hexagon/base64 を使いました。(もしかしたら使えるのかも知れません)
ちなみに他の言語で RP と Client を実装するという場合には @github/webauthn-json がオススメです。認証器からもらった情報をそのまま JSON として扱えるので、これを使わない手はありません。
実装の中身
データベース
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL
);
CREATE TABLE `webauthn_credentials` (
`public_key` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)
);
CREATE UNIQUE INDEX `id_idx` ON `users` (`id`);
CREATE UNIQUE INDEX `user_id_idx` ON `webauthn_credentials` (`user_id`);
今回の Passkey Autofill では、これまで我々が 2FA として利用してきたような WebAuthn と異なっていて、認証の最初で誰であるかを知ることはできません。なので、Client-side discoverable Credential (Resident Key) というものを利用しています。(という認識)
WebAuthnで"Client-side discoverable Credential"の挙動を確認する
ロジック
今回は Client-side discoverable Credential ログイン + Passkey autofill を実装します。
ログイン兼登録ページ
ユーザーが WebAuthn 認証器を登録および認証に使用するログインページを表示します。
この画面では、Passkey autofil (conditional mediation) が実装されています。
ユーザーはまず画面にアクセスすると beginAssertionOptions()
が発火し POST /assertion/options にリクエストします。その後、startAuthentication は conditional mediation
を使った認証のリクエストを行います。 これを行うことで、いきなりユーザーは認証器の選択を求められません。
ユーザー名の input タグには webauthn
type 属性が設定されており、ここにフォーカスすると、オートフィル欄に利用できるパスキーを表示します。このとき、ユーザーが持っていればそのままログインシーケンスに移行し、Assertion Objectを取得できた場合は POST /assertion/result にそれを送信します。認証ができた場合はそのままセッションを発行し、/profile にリダイレクトします。
一方で、ユーザーは利用できるパスキーを持っていない場合はユーザー名をそのまま入力します。登録ボタンを押下すると、POST /login にユーザー名を送ります。その後、ユーザー名と紐付けたセッションを Cloudflare Workersで発行し返却します。
セッションを取得したら、そのまま POST /attestation/options にリクエストを送信しブラウザ側で発行するためのチャレンジを取得します。チャレンジは Cloudflare KV に保存しておきます。
チャレンジを取得したブラウザは認証器の登録を要求(navigator.creedntails.create)を実行し、Attestation Object を取得してそれをPOST /attestation/result に送信します。
POST /login
/login という名前はサボりました。
ユーザー名を受け取り、セッションに保存します。このあとブラウザは認証器の選択を行うので、やってることはそれだけです。
登録
POST /attestation/options
ブラウザ側で PublicKeyCredentialCreationOptions を作成するためのリクエストです。リクエストの生成のためには事前にセッションに格納したユーザー名などを突っ込んでそれから作って返します。このときライブラリから得られたチャレンジも Cloudflare KV に保存しておきます。
POST /attestation/result
ブラウザから送信された AuthenticatorAttestationResponse を検証し、検証が成功した場合は User にユーザーを作成しつつ、鍵の情報を Cloudflare D1 に保存します。
ログイン
POST /assertion/options
チャレンジを生成するためのリクエストです。通常の WebAuthn ではこの中にユーザー名などを入れていましたが、そもそも Passkey Autofill ではユーザー名の特定のしようがないですので、ボディには何も含めません。
これを受け取って、ブラウザは認証器に署名したものを送らせます。
POST /assertion/result
ブラウザから送信された認証器のレスポンスを検証し、検証が成功した場合はセッションを発行し、/profile にリダイレクトします。
認証機構は独自実装ではなく、 remix-auth と呼ばれるライブラリを利用しています。remix-auth には Passport.js のような Strategy として認証ロジックを独自に拡張することができるため、今回の WebAuthn のシーケンスに無理矢理対応させました。
セッションストアはすべて Cloudflare KV です。Remix のセッション機能は標準で Cloudflare KV に対応しています。
データベースへの接続
ユーザーのデータを Cloudflare Workers -> Cloudlfare D1 で保存しています。
ORM として drizzle-orm というものを使っています。Twitter でやや話題になっていたので、詳しい使い方は Cloudflare D1 で ORM を使う (drizzle-orm) を読むと分かりますので、ここでは割愛します。この記事がとても参考になりました。
なお D1 はまだトランザクションをサポートしていないので注意が必要です。
おわり
実際に使えるかというと、いろいろ抜けている部分もありますが PoC レベルのものができました。エッジで動いてると思うとすごい。
参考情報
とても参考になりました、ありがとうございました・・・!
- Cloudflare WorkersのService BindingsこそRemixアプリケーションでは積極的に採用したい
- Cloudflare Workers を活かしきるスタックを考えた(remix+d1 on pages-functions) + 残タスク ... remix-auth + Workers 構成のコードがとても参考になりました
- WebAuthn メモ
- Cloudflare Workers メモ
- Passkey autofillを利用したパスワードレスログイン導入で得たものと、得られなかったもの - Money Forward Developers Blog
- Developers Summit 2023にてパスキーについて講演しました
Discussion