デジタル認証アプリAPI その1
はじめに
OAuth2.0やOIDC(OpenID Connect)について何にも知らなかったエンジニアが、デジタル認証アプリAPIに対応してサイトにマイナンバーカードとデジタル認証アプリを使った本人確認・認証機能を組み込むまでの顛末をつづりました。
対象読者
これからデジタル認証アプリAPIを使ってマイナンバーカードを使った本人確認・認証機能をシステムに組み込むエンジニア
準備
クライアント認証用のJWKS
デジタル認証アプリAPIに対応するコーディングの前に事務的な手続きが必要です。
具体的なところはデジタル庁が提示する資料のとおりなのですが、手続きに必要な申込書等を埋めていくなかでひとつ困ったのが「クライアント認証用のJWKS」です。
資料には、RPとデジタル認証アプリバックエンドサーバ間でクライアント認証を行うための公開鍵を記入、と説明があるのですが、この時点でOAuth2.0やOIDCに関連する知識がなく、これが何か、なぜ必要なのか、どこで使うのか、どうやって作るのかなど基本的なところがわかっていません。
ネットやAIを使って情報収集し、キーペアを作ってからJWKSにする方法など、いくつか試みましたが知識不足からか期待した結果とならず、結局のところ以下のサイトを利用しました。
JWK Set>Generate>JWK Generator>Generate a new key
使うぶんには難しくありませんが、注意点があり、↑の操作で信頼できる秘密鍵を作りたいのなら、このサイト上で操作して作るのではなく、このサイトを自己ホストして作ってね、と説明があります。
自己ホストしてとは?と思いましたが、親切なことにこのサイトのDockerイメージが準備されているためDocker環境さえあれば簡単に作ることができました。
その他決めておく必要があるもの
- リダイレクトURI
学習
デジタル庁発信情報
まずはデジタル庁発信情報に目を通しました。
デジタル庁開発者サイトの以下のページに集約されています。
1.
2.
3.
OAuth2.0やOIDCについて知識がないままここを読んでも、APIを使ううえでのおおまかなイメージはつかめます。ただ、わかるのはそこまでで、この先コードを書こうと思っても手は動きませんし、書いたコードをデバッグしようにも何が期待の動作なのかも解からず…、やはり、OAuth2.0やOIDCについてある程度の理解は必要のようです。
この時点で、わけがわからなかったことを挙げると、
- 認可コードフロー、トークンリフレッシュフローなどの○○フロー
- クレーム
- JWS,JWE,JWT,JWKなどの違い
- nonce
- PKCE
- 署名と暗号化、使うキーペア、使う鍵は、どっちがやる? など
OAuth2.0とOIDC
これらについてはネットに日本語情報も豊富で困りません。検索でヒットした複数の情報を取捨選択し、AIも駆使して検証しながら自分なりの理解を組み立てていくスタイルで学習できると思います。
ご参考までに私にとっていちばん価値ある情報となったのはこちらでした。
Authlete OAuth & OIDC 入門編 by #authlete
実装のあれこれ
事前に手に入れておくもの
- テスト用クライアントID
テスト方式
ターゲットとなる自らの開発環境(RP)とデジタル庁が用意したデジタル認証アプリサービスのSandboxを相手に動作を検証する
前提
- RPはWebアプリ
- 認証のみで署名は使わない
- 基本4情報は使う
フレームワークとプログラミング言語など
- バックエンド(RPを兼ねる) FastAPI Python
- フロントエンド Vue3 Composition API JavaScript
- データストア MongoDB
その他
SSL証明書
これまで開発環境ではplugin-basic-sslプラグインを使ったhttps通信環境でテストしていましたが、デジタル認証アプリサービスでは、https通信のみ、自己証明書不可というルールですので、このタイミングでLet's EncryptでSSL証明書を取得しました。当初、….ap-northeast-1.compute.amazonaws.comといったEC2がインスタンスに割り当てたホスト名で取得を試みましたがポリシー違反で取得できませんでした。動的ホスト名はだめ、つまり、カスタムドメインが必要となりますので、空きのドメインのストックがない場合は準備が必要です。
実装
実装のあれこれを、デジタル庁発信情報1.に記載がある「シーケンス図:認証」のリクエスト・レスポンスの括りで書いていきます。
[03]認可リクエスト[04]リダイレクト
認可エンドポイントを呼ぶ(リダイレクトする)ことで、利用者とデジタル認証アプリサービスのやり取りによる認証・認可手続きがトリガーされ実行されたあと、その結果を認可レスポンスとして取得する処理です。
必要なクエリパラメータを連結してエンドポイントを呼び出せばよい、と考えRESTAPIの呼び出しイメージで実装したのですが期待する結果とはならず大きくつまずくことになりました。
つまずきポイントは意図せずCORSエラーが通知されたことです。
デジタル庁発信情報1.シーケンス図:認証に以下のやり取りが説明されています。
ブラウザ RP
[02]認証開始リクエスト ー>
<ーーー [03]認可リクエスト
[04]リダイレクト ーーーー>
このやり取りの図から、私はRP側で生成した[03]認可リクエストはRP側の指示によるリダイレクトによってデジタル認証アプリサービスへ送信しなければならないもの、と理解していました。
よって、バックエンドの認証開始リクエストAPIは、必要なクエリパラメータを生成して認可エンドポイントに連結、リダイレクト先URLとしてRedirectResponse()に与えてリターンする処理として実装しました。意図したとおり、認可リクエストがリダイレクトされデジタル認証アプリサービスに送信されたのはよかったのですが、ブラウザーはCORSポリシー違反として以下のエラーを通知しました。
No 'Access-Control-Allow-Origin' header is present on the requested resource.
この時点で私は、こちら側の実装に問題はなくAccess-Control-Allow-Originヘッダーを返さないデジタル認証アプリサービス側の問題だろうと考え、デジタル庁に問い合わせを行ったところ意外な回答がありました。
認可エンドポイントリクエストは、クライアントが異なるオリジンのリソースに直接アクセスするわけではなく、認可サーバ(デジタル認証アプリサーバ)がリダイレクト先のURLを指示するため、本来CORSの必要性がない形が正しいものです。
以下OAuth 2.0のセキュリティに関するベストプラクティスを定義するRFC9700において、RFC9700: Best Current Practice for OAuth 2.0 Security However, CORS MUST NOT be supported at the authorization endpoint, as the client does not access this endpoint directly; instead, the client redirects the user agent to it.¶
とある通り、「認可エンドポイントではCORSはサポート外とすべき」と記載されております。
はて「本来CORSの必要性がない形が正しいもの」とは何?となりAIに相談。
「CORSの必要性がない」というのは、OAuth2.0 の認可エンドポイントにおけるリクエストの仕組みが、クライアント(ブラウザやアプリケーション)が直接リクエストを送信する形ではないため、CORSをサポートする必要がない、という意味です。
なんと!
今回のエラーは、クライアントが認可エンドポイントに対して XMLHttpRequest を使用して直接リクエストを送信しているために発生しています。ブラウザは、このリクエストがクロスオリジンであると判断し、CORS ポリシーを適用します。
認可サーバーは CORS をサポートしていないため、Access-Control-Allow-Origin ヘッダーがレスポンスに含まれず、ブラウザがリクエストをブロックします。
と、エラーの原因まで詳しく説明してくれました。
バックエンドの認証開始リクエストAPIは、他のバックエンドAPIと同様にAxiosを利用して呼び出していたのですがAxiosは内部でXMLHttpRequest()を使っているとのこと。結果、AIの言うとおりブラウザは認可リクエストをクロスオリジンと判定しCORSポリシーを適用した、というわけです。
そっかー。
というわけで、バックエンドの認証開始リクエストAPIは、必要なクエリパラメータを生成し認可エンドポイントに連結してリダイレクト先URL文字列を生成してリターンするまでの機能とし、実際の認可リクエストの送信は、window.location.hrefを使うよう修正したところ、期待どおりの結果(利用者とデジタル認証アプリサービスのやり取りによる認証・認可手続きがトリガーされる)となりました。
このつまずき、認可リクエストをRESTAPIの呼び出しと考えたことが大きな間違いで、認可リクエストは単なる画面の移動(画面遷移)と考えよ、が正解でした。
[06]認可レスポンス[07]リダイレクト
送信した認可リクエストが正常と判断された場合、デジタル認証アプリサービスは認可レスポンスのクエリーパラメータに次の3つの情報を設定します。
- code デジタル認証アプリサービスが生成した認可コード
- state RPが認可リクエスト時に生成したstate
- session_state デジタル認証アプリサービスが生成したセッション管理用の値
認可レスポンスの処理を実装していて?と感じたのが、受信した認可レスポンスは果たしてどの認可リクエストに対する認可レスポンスなのか、です。RPは、複数の認可リクエストを連続して処理することがあるので、受信した認可レスポンスがどの認可リクエストに対するレスポンスなのか紐づけるメカニズムが必要です。結局のところ、この紐づけにはstateを使うしかないようで、デジタル庁発信情報を読む限りstateはCSRF対策パラメータと説明されていますが、認可リクエストと認可レスポンスを紐づけるという役割もあることに注意です。
なお、AIに確認したところ以下の回答があり、そもそもそうであることが確認できました。
OAuth 2.0 の仕様 (RFC 6749) では、state の役割として以下が記載されています。
The "state" parameter is used to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client.
このことから、RPはいわゆるセッションのしくみが求められるということなりますので、実装としては、認可リクエスト処理時に生成したstateをキーとして他の認可リクエストクエリーパラメータや関連のユーザー識別情報、作成日時、有効期限などをまとめてデータストアに保存、認可レスポンス処理時点で受信したstateをキーにデータストアを検索、紐づく認可リクエスト情報を得るといった手続きが必要です。
なお、stateをリダイレクトURIの一部に組み込むことで認可リクエストと紐づける方法もありかと思いますが、デジタル認証アプリサービスではリダイレクトURIは事前に届け出の必要があるため、動的なリダイレクトURIは不可と思われます。知らんけど。
CSRF対策パラメータ(state)の検証
認可リクエスト送信時に生成したstateと受信した認可レスポンスのクエリーパラメータに設定されたstateが同じ、を確認することでCSRFによる攻撃の可能性を除外するという処理です。
テストを始めたところ、なぜかときどきstateが一致しない、という現象に遭遇しました。
original <>tl~3[^&{_vn}dp;`;:Nb!,X`<hW(Rh
Request state=%3C%3Etl~3%5B%5E%26%7B_vn%7Ddp%3B%60%3B%3ANb%21%2CX%60%3ChW%28Rh
Response state=%3C%3Etl~3%5B%5E%26%7B_vn%7Ddp
received <>tl~3[^&{_vn}dp
stateをそれぞれのシーンでダンプして確認したところ、上記のように不一致というか受信した認可レスポンスにセットされたstateが途中で終わっていることがわかり、さらにテストを繰り返した結果、テストした範囲では、state文字列に;(セミコロン)が含まれていた場合にこの現象が起きることを確認しました。
stateについては、デジタル庁発信情報2.のAPIリファレンスに以下のように規定されています。
string [ 1 .. 255 ] characters [\x20-\x7E]
256ビットのエントロピーを推奨
なので、以下のようなコードで生成していました。
VSCHAR = string.ascii_letters + string.digits + string.punctuation + ' '
def generate_entropy(self, length=32):
entropy = ''.join(secrets.choice(VSCHAR) for _ in range(length))
return entropy
セミコロンも許容されると理解していたわけですが、stateの文字種についてネット情報を収集したところ、みつけた範囲ではいずれのサイトでもhexdigitsだけで構成された文字列をstateに使っていました。AIに相談すると、・RFC6749ではstateの具体的な文字種について明確な制限は記載なし、・stateは「不透明な値(opaque value)」として扱われるため任意の文字列を使用できる、・stateは URLエンコードされるためURLセーフな文字列を使用することを推奨、といった回答だったので現状のコードで問題ないのではと思いつつも、OIDCの先人の流儀にあわせるべきと考え、深くは追究せずに以下のようにコードに改めて乗り切りました。
VSCHAR = '01234567890abcdef'
def generate_entropy(self, length=32):
entropy = ''.join(secrets.choice(VSCHAR) for _ in range(length))
return entropy
続く
Discussion