【図解・コード有】Amazon Cognito を用いた2種類のパスキーの実装〜マネージドログインと Cognito Identity Provider API〜

こんにちは、DWS 2人目の大島です。
Amazon Cognito が正式にパスキーを機能としてサポートしました。

以前、Amazon Cognito を利用したパスキーの仕組みに関する記事を書いたのですが、当時は Cognito のカスタム認証フローを利用して自前でパスキーに関する諸々を準備しなければならず、実装が非常に手間のかかるものでした。

今回 Cognito ネイティブの機能で簡単に実装ができるようになりましたので、実際に新しい機能を利用してパスキー登録・認証ができるサンプルアプリケーションを実装しました。
Cognito やパスキーの解説も交えながら、確認していきたいと思います。
2つの実装方法
Cognito ではパスキーの実装を行うにあたって、現在大きく以下2種類の実装方法があります。
(そもそも Cognito を利用する場合に2種類の実装方法がある、というのが正確ですが)
- マネージドログイン
- Cognito Identity Provider API
①の「マネージドログイン」は、Amazon Cognito がホストしてくれるログイン画面をそのまま利用することができる機能です。
アプリにログイン機能を付けたいときに、自分でログイン画面を作らなくても、Cognito がホストしている画面に飛ばすだけで、ログインの仕組みを簡単に実装できます。
以前は Hosted UI という名前で利用されていた機能で、今回名称が変更となりました。
Cognito 側が用意している UI となるので、独自の UI を作りたいと言ったケースでは採用できませんが、面倒な上にセキュリティリスクが大きく伴う認証周りの実装を自前でやる必要がなくなるので、開発コストとリスクを大幅に削減できます。

②の「Cognito Identiy Provider API」とは、UI を自前で用意して Cognito の認証機能を利用したい場合に使われる API 群です。
マネージドログイン機能と違い、自分自身で認証の機能を理解して API を叩く実装をする必要があり、UI の構築も必要になりますが、その分独自の UI の実現が可能となります。
コードについて
今回 Cognito のパスキー認証を実装したコードはこちらの Github 上で公開しています。
マネージドログイン・ Cognito Identity Provider API どちらの実装も一つのページ上で実装していますが、実態としては完全に別のアプリケーションになっています。
(後述しますが、Cognito の設定を切り替えないとそれぞれ動作しません)
また、あくまでサンプルアプリになので意図せぬ挙動やセキュリティ的な懸念もあるかもしれませんので、流用などはご自身の責任でお願い致します。
今回サンプルアプリケーションを動かすには、事前にいくつか設定が必要です。
アプリケーションを手元で動かす場合、READMEを参考にしてください。

マネージドログインの実装
マネージドログインは、ログイン画面を Cognito のドメインでホストしてくれます。
基本的には作成時にすでに設定されていることが大半ですが、Cognito の設定の中でドメインを追加すると、Cognito の前段に CloudFront が追加されます。
(この CloudFront はユーザー側のリソースとしては確認できません)

この Cognito のドメインでは、ユーザーがパスキーを登録している場合、以下のようなパスキーログインの画面が表示され、ログインができるようになります。

パスキーの登録自体は、ユーザーの初回サインイン時に自動的に追加画面が表示され、可能となります。
今回は特に実装はしていませんが、初回に追加をしなかった場合でも、パスキー登録用のエンドポイントにアクセスすることで後からの登録は可能なようです。
サンプルアプリケーションの「Sign in」画面からマネージドログイン画面へ遷移してログインフローを開始できます。

マネージドログインは、以下のようなエンドポイントの集合であり、認証で必要となる様々な機能を Cognito の設定をするだけで簡単に利用することができます。

マネージドログインでの実装は、Cognito の設定と、マネージドログイン画面へ飛ばすだけのシンプルなものなので、コードを見ていただく方が早いかもしれません。
認証周りの処理を実装する場合、ライブラリを利用することが多いかと思いますので、今回は Auth.js 利用しています。
マネージドログインの実装は、/oauth2/authorizeというエンドポイントへ必要なパラメータを添えてアクセスし、そこから/loginへリダイレクトするような動きとなっています。

マネージドログイン画面に遷移した後は、Cognito 側が用意してくれている実装でログイン処理が進みます。
画面に沿って新たにアカウントを作成すると、以下のようなパスキー追加画面が出てきますので、パスキーの追加が可能となります。

マネージドログインの解説は以上となります。
次は Cognito Identity Provider API を見ていきます。
Cognito Identity Provider API での実装
マネージドログインでは、パスキーの裏側は良くも悪くもブラックボックスになっていますので、動作の理解は難しいでしょう。
そこで、Cognito Identity Provider API を使って、自分でパスキーの実装を行うパターンを見ていきます。
前回の記事でパスキーの動作については説明していますが、改めておさらいしておきます。
パスキーの動作
パスキーでは「リライングパーティ」というWebAuthentication API(パスキーを利用するための Web API)を使ってをユーザーを登録・認証するエンティティと、「認証器」という鍵ペアを発行するエンティティの2つを意識しておく必要があります。
わかりやすくいってしまえば、リライングパーティはアプリケーション、認証器はパスワードマネージャーツールです。
上記を踏まえて、まずは鍵の登録フローをみていきます。
今回のケースでは、リライングパーティ = サンプルアプリケーションで、認証器 = ブラウザ(のパスワード管理機能)となります。
Cognito はサンプルアプリケーションの中の認証を担う一機能とみなせるので、リライングパーティの中に含まれます。(Cognito + サンプルアプリケーション = リライングパーティ)
パスキーのベースはシンプルな公開鍵暗号を利用した認証の方式です。
リライングパーティからチャレンジを送信し、認証器は秘密鍵と公開鍵のペアを作成してチャレンジの署名とともに公開鍵を返却します。
この時、作成された秘密鍵はチャレンジと一緒に送信されたリライングパーティを一意に識別するデータと紐づけて管理されます。

認証を行う流れも登録と大きくは変わりません。
まずは、同様にリライングパーティからチャレンジを送信します。
この時、認証器はチャレンジとともに送られてきたリライングパーティを識別する情報をもとに、正しい秘密鍵を選択してチャレンジを署名して返却します。
リライングパーティは公開鍵を利用して署名を検証するのが大まかな流れです。

実装
パスキーのおおまかな動作を確認したところで、 Cognito Identity Provider API を使った実装を見ていきたいと思いますが、その前に一点対応が必要です。
Cognito Identity Provider API を利用してパスキーを利用する場合、Cognito 上の ID のドメイン設定を変更して RP ID を切り替えなければなりません。
マネージドログインを利用した場合は、認証を行うのは Cognito でホストされたページなので RP ID は、cloudfront のドメインで発行されます。
一方、自分で実装した場合はアプリのページになるので、自分のアプリのドメインを設定しておく必要があります。(今回は localhost)

登録フロー
まず、登録フローから見ていきます。
画面の 「Register passkey」のボタンからフローを開始できます。

自分でパスキーの実装をする場合、認証器とのやり取りを行なってくれるブラウザに用意された Web Authentication API (WebAuthn) を利用する必要があります。
Web Authentication API は、公開鍵暗号(public-key cryptography)を用いた認証を提供する API で、認証情報を取り扱う Credential Management API を拡張して作られています。
自身で実装するコードは、基本的に Cognito Identity Provider API とWeb Authentication API の間で情報を仲介するような役目になります。
APIの流れを先ほどの図に沿って書いてみると、以下のようなイメージです。

まず、Cognito から認証器(ブラウザ)に渡すためのチャレンジや RP ID などの情報をもらいます。
次に、 パスキーを登録するための WebAuthentication API である navigator.credentials.create を利用します。
Cognito の StartWebAuthnRegistration は、navigator.credentials.createに渡すべきオブジェクト(CredentialCreationOptions)をそのままレスポンスとして返してくれるのでデータ自体は横に流すだけでOKです。
返り値として認証器は、認証の正当性を証明するためのデータを表すオブジェクト(attestation オブジェクト)を返却します。
ここで注意点として、attestationオブジェクトなどを含むレスポンスのデータは CBOR という形でエンコードされているものがあり、Cognito に渡す前にデコードが必要なため、生で取り扱うには色々と面倒なことが多いです。
なので今回はその辺りの面倒な部分を吸収してくれる SimpleWebAuthn というライブラリを利用しています。
実際にプロジェクトで採用する場合にも、ライブラリを使った方が良いでしょう。

最後に返却された公開鍵とチャレンジを Cognito に返却して、終了となります。
Cognito で公開鍵を保管してくれるので、以降、ユーザーはパスキーを使ったログインができるようになります。
認証フロー
次は認証フローです。
画面の「Login with passkey」のボタンからフローを開始できます。

先ほどと同様に、APIの手順を示すと以下のようになります。

認証を開始するためのInitiateAuthを実行します。
登録の際と同様、公開鍵認証に使用するためのチャレンジ値や RP IDなどが返却されます。
Cognito には Admin 系の API (AdminInitiateAuth など)と、非 Admin 系(InitiateAuth など)の2種類の API が用意されていますが、今回は単純なユーザーのログインだけなので非 Admin 系の API を利用しています。
次に、 パスキーを利用するための navigator.credentials.get を利用します。
秘密鍵でのチャレンジの署名が完了すると、認証器でのフロー完了の証明として assertion と呼ばれるオブジェクトとして署名を含めて返却します。
こちらも同様に CBOR が利用されているため、ライブラリを利用しています。
最後に認証器から返却された assertion オブジェクトを返却して終了です。
返り値として、アクセストークンやリフレッシュトークンなどが返却されます。
削除フロー
最後に、参考程度ですが Cognito では登録した鍵の削除も可能です。

以下のような手順になります。
後述しますが、削除の手順では認証器は関わってきません。

対象のユーザーが登録している Cognito 上のパスキーを全てリストアップします。
ここで取得した鍵のIDを使って、次のDeleteWebAuthnCredentialsを利用して鍵の削除を行います。
利用にはユーザーのアクセストークンが必要です。
指定した鍵を Cognito 上から削除します。
今回はユーザーに紐づいている鍵を全て削除するような形にしています。
削除されるのはあくまで Cognito 上の公開鍵だけで、認証器上に登録された秘密鍵は javascript から操作して削除をすることは現在できず、ユーザーが自身で削除しなければいけないようです。

終わりに
こうしてみていくと、パスキーの実装が非常に簡単になったことがわかります。
まだドキュメントが少し不足している感じはありますが、パスキーのフローが理解できれば Cognito Identity Provider API での実装もそこまで難しくないでしょう。
Cognito を用いたパスキーでの認証に関しての理解の一助になれば幸いです。